资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑

**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-24 10:26:45 +08:00
co-authored by Claude Opus 5
parent 70e83d0b09
commit 0aa2fc11b0
92 changed files with 13984 additions and 370 deletions
@@ -0,0 +1,267 @@
/**
* I01 品牌形象与销售工具包管理的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面),
* 方法照抄 E04(`crm-relationship-coverage`)已验证过的测试结构。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(I01 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('L4 判据不给谓词 —— 是裁量项,不是机检项', () => {
const l4 = manifest.contract.acceptanceCriteria.filter((c) => c.level === 'L4');
expect(l4.length).toBe(3);
for (const c of l4) expect(c.predicate, c.key).toBeUndefined();
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
expect(manifest.contract.requiredDeliverables).toHaveLength(3);
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(照抄 E04 2026-08-20 实测栽过
* 那条纪律):基线三次(S2 / S4 通过 / T_release)+ 两轮决策卡循环(每轮 +2)= 7,留够余量。
*/
it('人环预算留够余量:基线三次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 3;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,reason/requester/targetCustomer/note 都可选 —— 两级下拉的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'reason', 'requester', 'targetCustomer', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['reason']?.showWhen).toEqual({ field: 'trigger', equals: 'proactive' });
expect(properties['requester']?.showWhen).toEqual({ field: 'trigger', equals: 'reactive' });
});
it('四条样例形状真不同(触发路径各不相同),各自的入参都能通过对应 showWhen 校验', () => {
// 第四条是「资料文件」那一环的常备案例(一封需求要三种形态,格式分得开)
expect(manifest.samples).toHaveLength(4);
const shapes = manifest.samples.map((s) => JSON.stringify(s.input));
expect(new Set(shapes).size).toBe(4);
const proactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'proactive');
expect(proactive).toBeDefined();
const reactive = manifest.samples.find((s) => (s.input as { trigger?: string }).trigger === 'reactive');
expect(reactive).toBeDefined();
expect((reactive?.input as { requester?: string }).requester).toBe('E04');
expect((reactive?.input as { targetCustomer?: string }).targetCustomer).toBe('CU-0142');
});
it('三张视图模版都声明了,案例模型每一步引用的 view 都在其中', () => {
const names = new Set(manifest.assets.viewTemplates.map((v) => v.name));
expect(names).toEqual(new Set(['requirement-list', 'draft-review', 'publish-confirm']));
for (const step of manifest.assets.businessModel.steps as ReadonlyArray<{ view?: string }>) {
if (step.view !== undefined) expect(names.has(step.view), step.view).toBe(true);
}
});
});
describe('台账声明:三级占位与自有台账', () => {
const byAddress = (domain: string, object: string) =>
manifest.declarations.ledger.find((l) => l.domain === domain && l.object === object);
it('L-01 市场信号只读:本活动不写它,字段不带 required/expect', () => {
const signal = byAddress('market', 'signal');
expect(signal).toBeDefined();
for (const f of signal?.fields ?? []) {
expect(f.required, f.name).not.toBe(true);
expect(f.expect, f.name).not.toBe(true);
}
});
it('L-05 内容资产:I01 唯一读写者,必填列齐全(content_type/title/language/version/content_status)', () => {
const asset = byAddress('content', 'asset');
expect(asset).toBeDefined();
const required = new Set(
(asset?.fields ?? []).filter((f) => f.required === true).map((f) => f.name),
);
for (const name of ['content_type', 'title', 'language', 'version', 'content_status']) {
expect(required.has(name), name).toBe(true);
}
const status = asset?.fields.find((f) => f.name === 'content_status');
expect(status?.tally).toEqual(['draft', 'reviewed', 'published', 'deprecated']);
});
it('内容需求清单主键是 round_ref + req_seq(没有天然外部键,照抄 E04 ledger_update.update_seq 的处置)', () => {
const requirement = byAddress('content_brand', 'requirement');
const keys = (requirement?.fields ?? []).filter((f) => f.primaryKey).map((f) => f.name);
expect(keys).toEqual(['round_ref', 'req_seq']);
});
it('优先级是 ASCII tally(high/medium/low),报告靠 data-class-of 投影,不靠模型写中文', () => {
const requirement = byAddress('content_brand', 'requirement');
const priority = requirement?.fields.find((f) => f.name === 'priority');
expect(priority?.tally).toEqual(['high', 'medium', 'low']);
});
});
describe('案例模型与清单的四处对齐', () => {
it('步骤剧本的 item 名与 CMMN humanTask id 一一对应', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item);
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(stepItems).toEqual(cmmnIds);
});
it('六个计划项:1 执行 + 2 验收(S2 执行工单,S4/T_release 验收工单)', () => {
const cmmnIds = [...manifest.assets.businessModel.cmmnXml.matchAll(/<humanTask id="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(cmmnIds).toEqual([
'S1_identify_requirement',
'S2_requirement_confirm',
'S3_gather_and_draft',
'S4_draft_review',
'S5_publish_prep',
'T_release',
]);
});
it('三处不给 doneWhen(交人核对/要人拍板/签下去触达世界),其余三处都给了', () => {
const steps = manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; doneWhen?: string }>;
const noDoneWhen = new Set(['S2_requirement_confirm', 'S4_draft_review', 'T_release']);
for (const s of steps) {
if (noDoneWhen.has(s.item)) expect(s.doneWhen, s.item).toBeUndefined();
else expect(s.doneWhen, s.item).toBeDefined();
}
});
it('S2/S4/T_release 三项在 CMMN 里都标了人工激活(manualActivationRule)', () => {
for (const id of ['S2_requirement_confirm', 'S4_draft_review', 'T_release']) {
const block = new RegExp(`<humanTask id="${id}"[\\s\\S]*?</humanTask>`).exec(manifest.assets.businessModel.cmmnXml)?.[0] ?? '';
expect(block, id).toContain('manualActivationRule');
}
});
it('除放行外每一项恰好属于一个阶段(进度条刻度不重不漏)', () => {
const stepItems = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>)
.map((s) => s.item)
.filter((item) => item !== 'T_release');
const phaseItems = (manifest.assets.businessModel.phases ?? []).flatMap((p) => p.items);
expect([...phaseItems].sort()).toEqual([...stepItems].sort());
expect(new Set(phaseItems).size).toBe(phaseItems.length);
});
it('说明书点名的计划项都在案例模型里真实存在(没有臆造的 S-id)', () => {
const stepItems = new Set((manifest.assets.businessModel.steps as ReadonlyArray<{ item: string }>).map((s) => s.item));
// 只认「S数字_字母」这种完整计划项形状 —— 裸的「S1」「S1/S2/S3」是自然语言里的口语简写
// (如「见下方 S1 的 guide」),不是在点名一个具体的计划项 id,不该被当成引用校验。
const mentioned = [...manifest.assets.businessModel.narrative.matchAll(/\b(S\d_[a-zA-Z_]+|T_release)\b/g)].map((m) => m[1] ?? '');
for (const name of mentioned) {
expect(stepItems.has(name), `说明书提到但不存在的计划项: ${name}`).toBe(true);
}
});
it('决策卡骨架(S4_draft_review)三种 value 与说明书一致:approve/reject/custom:', () => {
expect(manifest.assets.businessModel.narrative).toContain('"kind":"decide","itemId":"S4_draft_review"');
expect(manifest.assets.businessModel.narrative).toContain('"approve"');
expect(manifest.assets.businessModel.narrative).toContain('"reject"');
expect(manifest.assets.businessModel.narrative).toContain('"custom:"');
});
/*
* **T_release 的 judge criterion 必须逐字抄 acceptanceCriteria 的 label**(2026-08-21
* 实测逼出):剧本写了缩写版"版本号与状态是否正确",清单的 label 是
* "版本号与状态是否正确:版本号已更新、状态流转无误"——两边对不上,模型照剧本裁的那句
* 匹配不到清单上的判据,提交放行永远差一条"[L4] 还没裁过",连拒五拍熔断。
* 这条测试钉死:T_release 的 guide 里每一句 `judge criterion="..."` 都要能在
* acceptanceCriteria 的 label 集合里原样找到。
*/
it('T_release 剧本里 judge criterion 的原文,逐字对得上 acceptanceCriteria 的 label', () => {
const tRelease = (manifest.assets.businessModel.steps as ReadonlyArray<{ item: string; guide?: string }>).find(
(s) => s.item === 'T_release',
);
const quoted = [...(tRelease?.guide ?? '').matchAll(/judge criterion="([^"]+)"/g)].map((m) => m[1] ?? '');
expect(quoted.length).toBeGreaterThan(0);
const labels = new Set(manifest.contract.acceptanceCriteria.map((c) => c.label));
for (const q of quoted) expect(labels.has(q), q).toBe(true);
});
});
describe('报告模版:编辑面与只读面各就各位', () => {
it('内容需求清单可编辑:data-edit 与 data-col 都在', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(html).toContain('data-edit="content_brand.requirement"');
expect(html).toContain('data-col="content_type"');
});
it('内容初稿审核只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('发布确认只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'publish-confirm')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1] ?? ''));
const declared = new Set(
manifest.declarations.ledger.find((l) => l.domain === 'content_brand' && l.object === 'requirement')?.fields.map((f) => f.name),
);
for (const c of cols) expect(declared.has(c), c).toBe(true);
});
it('状态徽(优先级/内容状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const req = manifest.assets.viewTemplates.find((t) => t.name === 'requirement-list')?.html ?? '';
expect(req).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const draft = manifest.assets.viewTemplates.find((t) => t.name === 'draft-review')?.html ?? '';
expect(draft).toContain('<span class="jc-tag" data-class-of="content_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
describe('活动入口', () => {
it('没有 run —— 行为全在清单里', () => {
expect(activity.run).toBeUndefined();
});
});
const CONTRACT = {
activityId: 'brand-content-toolkit',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['content-published'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
@@ -0,0 +1,287 @@
/**
* 步骤剧本与呈现面 —— **这段错了不报错,只是活动开始转圈 / 界面开始撒谎**
* (与 quote-* / E04 各包同一套惯例)。
*
* 这一份是 2026-08-23 按 E04 的标准审 I01 时补的:每一条都对应审出来的一处真问题。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, CONTENT_PHASES, CONTENT_STEPS, NARRATIVE } from '../src/case-model.ts';
import { DRAFT_REVIEW, PUBLISH_CONFIRM, REQUIREMENT_LIST } from '../src/views/report.ts';
import { PRD_CSS } from '../src/views/prd-style.ts';
import { manifest } from '../src/manifest.ts';
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上', () => {
it('每一条剧本都指向一个真实存在的计划项,图上每一步也都有剧本', () => {
for (const step of CONTENT_STEPS) expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
for (const id of ITEM_IDS) expect(CONTENT_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(CONTENT_STEPS);
expect(manifest.assets.businessModel.phases).toBe(CONTENT_PHASES);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes / steps[].reads(台账那几个)全落在声明过的对象上', () => {
for (const step of CONTENT_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
for (const address of step.reads.filter((r) => r.includes('.'))) {
expect(declaredAddresses.has(address), `${step.item} 要读 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具(本活动一个工具都不用,那就都得是空)', () => {
for (const step of CONTENT_STEPS) {
for (const tool of step.tools) expect(declaredTools.has(tool), `${step.item} 要调 ${tool}`).toBe(true);
}
});
});
describe('doneWhen —— 开局不许真,干完必须真', () => {
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
content_brand_requirement_rows: 0,
content_asset_rows: 0,
content_asset_missing: 0,
content_asset_content_status_published: 0,
});
it('**开局一步都不该被自动收掉** —— 三条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of CONTENT_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
it('需求写下了 → S1 收得掉;初稿有行且必填齐 → S3 收得掉,缺一头都收不掉', () => {
expect(safeEval(doneOf('S1_identify_requirement'), { content_brand_requirement_rows: 2 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 0 })).toBe(true);
expect(safeEval(doneOf('S3_gather_and_draft'), { content_asset_rows: 1, content_asset_missing: 1 })).toBe(false);
});
it('拟发布:记账旗 + 已发布行数 + 必填齐,三样缺一不可', () => {
const done = doneOf('S5_publish_prep');
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 0 }),
).toBe(true);
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 0, content_asset_missing: 0 }),
).toBe(false);
// 场景/BT 标签是「发布态必填」—— 缺了会进 _missing,这一步就收不掉
expect(
safeEval(done, { publish_prepared_total: 1, content_asset_content_status_published: 1, content_asset_missing: 1 }),
).toBe(false);
});
});
describe('这一轮审出来的口径 —— 改口要连测试一起改', () => {
/*
* **两条 L1 各钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」的伪装形
* (E04 那单抢跑写旗、活单熔断,正是踩着这半边过的门)。
*/
it('两条 L1 都不只读记账旗,各自还钉着基座数', () => {
for (const key of ['requirement-confirmed', 'draft-approved']) {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === key);
expect(found?.predicate, `${key} 没有谓词`).toBeDefined();
expect(found?.predicate).toMatch(/_rows > 0/);
expect(found?.predicate).toMatch(/_missing = 0/);
}
});
/*
* **判据指向的东西必须摆在卡面上**(E04 那一课):L4「场景与 BT 标签是否登记完整」
* 从前让人核一个 `content.asset` 里压根不存在的列。
*/
it('BT 标签有列、发布态必填、两份验收件上都摆得出来', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
const bt = asset?.fields.find((f) => f.name === 'bt_tag');
expect(bt, 'content.asset 缺 bt_tag').toBeDefined();
expect(bt?.requiredWhen).toEqual({ column: 'content_status', value: 'published' });
// 不带 tally —— 件上是普通格子、没有翻译层,存 ASCII 人看到的就是 ASCII(scene 踩过)
expect(bt?.tally).toBeUndefined();
expect(DRAFT_REVIEW).toContain('data-slot="bt_tag"');
expect(PUBLISH_CONFIRM).toContain('data-slot="bt_tag"');
});
it('场景也是发布态必填 —— S5 剧本说「发布前必须登记」,声明就得钉得住', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
expect(asset?.fields.find((f) => f.name === 'scene')?.requiredWhen).toEqual({
column: 'content_status',
value: 'published',
});
});
/*
* **主键列必挂 `data-col`** —— 它不是编辑面,是**行的钥匙**(仓里立过的纪律,A1 从头就带)。
* 这一条是浏览器实测逼出来的:一度把序号列的 `data-col` 拿掉「让它只读」,
* 结果人改的老行也没了钥匙 —— 服务端只好去猜,把它当成新增行、又拿「全表一致」
* 猜出个 1,**当场覆盖掉原来那一行**。钥匙必须随行回传;
* 「人加的新行序号空着」那一半由基座续号(`resolveRowKey`)接住。
*/
it('需求清单的序号列挂着 data-col(行的钥匙),内容类型是可改的必填格', () => {
expect(REQUIREMENT_LIST).toContain('data-slot="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="req_seq"');
expect(REQUIREMENT_LIST).toContain('data-col="content_type"');
});
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = CONTENT_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['content.asset', 'content_brand.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
expect(release?.guide).toContain('覆盖写');
});
});
describe('三份工单模版照说明书 §6.2 的共用动态位', () => {
const ALL = [REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 三份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
expect(view).toContain('data-slot="round_ref"');
}
});
it('执行工单的编辑闸照目标件措辞:「AI 已识别 N 个内容需求」,N 是基座读数', () => {
expect(REQUIREMENT_LIST).toContain('AI 已识别');
expect(REQUIREMENT_LIST).toContain('data-slot="content_brand_requirement_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(REQUIREMENT_LIST).not.toContain('【本轮触发】');
});
it('样例 0 是满载的那条,且每条样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'requester', 'targetCustomer', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
for (const s of manifest.samples) {
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
}
});
});
function doneOf(item: string): string {
const found = CONTENT_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
/**
* **审核资料这一步要真产出资料文件**(2026-08-23 用户定:「格式可以是 word/excel/md/html,
* 文件需要能在前端渲染,在表格中点击资料名可以跳转到一个新的页面来渲染这些文件」)。
*
* 接手时这一步只写下一行标题 —— S3 的剧本白纸黑字写着「file_ref 选填,本 MVP 阶段可留空」:
* 人要审的那份初稿根本不在系统里,点不开也下不来。下面每一条都钉着这一轮补上的一环。
*/
describe('资料文件:声明 → 正文 → 格式 → 点得开', () => {
const asset = manifest.declarations.ledger.find((d) => d.domain === 'content' && d.object === 'asset');
it('台账声明了 file 段(它就是授权面 —— 不声明,件上点不开)', () => {
expect(asset?.file).toEqual({ titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' });
});
it('file 段点名的三列都真在 fields 里', () => {
const names = (asset?.fields ?? []).map((f) => f.name);
for (const col of Object.values(asset?.file ?? {})) expect(names, `声明的 ${col} 不在 fields 里`).toContain(col);
});
it('正文与格式都是**必填** —— 少一样这一份就不是文件,只是一行标题', () => {
for (const col of ['body_md', 'file_format']) {
expect(asset?.fields.find((f) => f.name === col)?.required, `${col} 不是必填`).toBe(true);
}
});
it('格式列带 tally 四档(基座据此现数,判据两边才都是基座数)', () => {
expect(asset?.fields.find((f) => f.name === 'file_format')?.tally).toEqual(['md', 'html', 'docx', 'xlsx']);
});
it('L2 判据「资料文件已产出」两边都是基座数:四档之和 = 行数', () => {
const found = manifest.contract.acceptanceCriteria.find((c) => c.key === 'asset-file-ready');
expect(found?.level).toBe('L2');
for (const fmt of ['md', 'html', 'docx', 'xlsx']) {
expect(found?.predicate, `谓词漏了 ${fmt}`).toContain(`content_asset_file_format_${fmt}`);
}
expect(found?.predicate).toContain('= content_asset_rows');
// 自报列一个都不许出现在机检谓词里
expect(found?.predicate).not.toMatch(/file_ref|reviewer/);
});
it('S3 的剧本教它写正文与挑格式,且不再说「可留空」', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.writes).toContain('content.asset');
expect(s3?.guide).toContain('body_md');
expect(s3?.guide).toContain('file_format');
for (const fmt of ['xlsx', 'docx', 'html', 'md']) expect(s3?.guide, `剧本没讲 ${fmt} 什么时候用`).toContain(fmt);
// 那句话正是「这一步不产出文件」的出处 —— 它在,行为就回去了
expect(s3?.guide).not.toContain('本 MVP 阶段可留空');
expect(s3?.guide).not.toContain('待补充');
});
it('S3 的 doneWhen 覆盖得住新的必填(必填齐才收步)', () => {
const s3 = CONTENT_STEPS.find((s) => s.item === 'S3_gather_and_draft');
expect(s3?.doneWhen).toContain('content_asset_missing = 0');
// 正文/格式没写 → missing 不为 0 → 收不掉
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 1 })).toBe(false);
expect(evaluateCondition(s3?.doneWhen ?? 'false', { content_asset_rows: 2, content_asset_missing: 0 })).toBe(true);
});
it('审核那一屏:资料名挂 data-file-of,旁边摆格式徽', () => {
expect(DRAFT_REVIEW).toContain('data-file-of="content_id"');
expect(DRAFT_REVIEW).toContain('data-class-of="file_format"');
// 归档那一屏同样点得开 —— 办结之后回看资料是常事
expect(PUBLISH_CONFIRM).toContain('data-file-of="content_id"');
});
it('资料名那一格看得出能点(样式表里有它,不是一格普通文字)', () => {
expect(DRAFT_REVIEW).toContain('jc-fileln');
expect(PRD_CSS).toContain('.jc-fileln');
expect(PRD_CSS).toContain('cursor: pointer');
});
});
/**
* **提交前要裁的那三条,写在说明书里**(2026-08-23 实测:S5 与 submit 被压成一拍,
* 那一拍摊开的是 S5 的剧本,T_release 的「先 judge 再 submit」看不见 → 每单白吃一次被拒)。
* 与 A1 的卡骨架、A6 的决策卡同一课:**模型会压拍,压过之后只有说明书还在**。
*/
describe('裁量判据的规矩住在说明书里,不只住在 T_release 的剧本里', () => {
it('三条 L4 的整句都在 NARRATIVE 里(criterion 要照抄,抄不到就对不上名)', () => {
for (const c of manifest.contract.acceptanceCriteria.filter((x) => x.level === 'L4')) {
expect(NARRATIVE, `说明书里没有「${c.label}」`).toContain(c.label);
}
});
it('说明书说清了顺序:同一串先 judge 再 submit', () => {
expect(NARRATIVE).toContain('judge');
expect(NARRATIVE).toContain('submit');
});
});
@@ -0,0 +1 @@
{ "name": "@activities/brand-content-toolkit", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,464 @@
/**
* I01 品牌形象与销售工具包管理的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。方法论与文件结构照抄 E04(`crm-relationship-coverage`)已验证过的样子。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,复盘⑥⑦独立拎出不并入)与
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01/L-05 台账字段表)。
*
* 7 个业务步收成 6 个案例模型项,非逐一对应:
* ① 需求识别与受理 → S1_identify_requirement(AI 起草)+ S2_requirement_confirm(人核对);
* ②③ 市场依据采集 + 内容创作 → S3_gather_and_draft(一项,AI,纯加工态);
* ④ 内部审核与调性把关 → S4_draft_review(决策卡循环,通过/驳回+AI重做);
* ⑤ 发布入库 → S5_publish_prep(AI 拟发布)+ T_release(终局验收放行)。
* ⑥⑦ 分发追踪与复盘迭代**不在这份案例模型里**(业务说明明文:工单只针对生产流程,
* 复盘是独立触发的另一条时间轴,不并入这一次生产任务)。
*
* 与 E04 的一处刻意不同:I01 没有「AI 做不到、必须人去线下执行」的环节
* (内容初稿由 AI 草拟,人只审不做)——所以只有 1 张执行工单(S2),
* 少 E04 那张「执行经营动作」;验收工单反倒多一张(S4 审初稿 + T_release 发布确认),
* 与 E04(S4 审计划 + T_release 台账更新)结构同型。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="brand-content-toolkit">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/reason/requester/targetCustomer/note
五个字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
content_brand_* 与 content_asset_* 那几个是基座读数(行数 / 缺必填 / 按值计数,
由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="content_brand_requirement_rows" name="内容需求行数(基座现数)"/>
<caseFileItem id="requirement_confirmed_total" name="内容需求已核对确认(人环走完的凭据)"/>
<caseFileItem id="content_asset_rows" name="内容资产行数(基座现数)"/>
<caseFileItem id="content_asset_missing" name="内容资产缺必填的行数(基座现数)"/>
<caseFileItem id="draft_approved_total" name="内容初稿已审核通过(仅通过时置一)"/>
<caseFileItem id="content_asset_content_status_published" name="已置为已发布状态的行数(基座现数)"/>
<caseFileItem id="publish_prepared_total" name="发布准备已收口(S5 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="品牌形象与销售工具包管理" autoComplete="true">
<!-- ① 内容需求已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_requirement_drafted">
<onPart id="op_rd" caseFileItemRef="content_brand_requirement_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rd"><condition>content_brand_requirement_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ① 内容需求已核对 —— 采集与创作那一步的入场前提 -->
<sentry id="s_requirement_confirmed">
<onPart id="op_rc" caseFileItemRef="requirement_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>requirement_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ②③ 内容初稿已草拟且齐 —— 审核那一步的入场前提 -->
<sentry id="s_draft_ready">
<onPart id="op_dr" caseFileItemRef="content_asset_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_dr"><condition>content_asset_rows &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ④ 内容初稿已审核通过 —— 拟发布那一步的入场前提 -->
<sentry id="s_draft_approved">
<onPart id="op_da" caseFileItemRef="draft_approved_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_da"><condition>draft_approved_total &gt; 0</condition></ifPart>
</sentry>
<!--
⑤ 发布已准备好 —— 放行门的入场前提。
认「办完了没有」:发布状态已置且不缺必填、且 S5 自己显式记了账
(publish_prepared_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_publish_ready">
<onPart id="op_pr" caseFileItemRef="publish_prepared_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_pr"><condition>publish_prepared_total &gt; 0 and content_asset_content_status_published &gt; 0 and content_asset_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别内容需求,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_requirement" name="识别本轮内容需求"/>
<!-- ① 交人核对需求清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_requirement_confirm" name="需求受理:核对需求清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_requirement_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③ 采集市场依据 → 草拟多语言内容初稿(一项,不触达世界) -->
<humanTask id="S3_gather_and_draft" name="采集市场依据、按 BT 层级草拟内容初稿">
<entryCriterion id="ec_s3" sentryRef="s_requirement_confirmed"/>
</humanTask>
<!-- ④ 内容初稿审核 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_draft_review" name="内容初稿审核:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_draft_ready"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤(前半)拟发布:版本号递增、状态流转、登记场景与 BT 标签(不触达世界,给 doneWhen) -->
<humanTask id="S5_publish_prep" name="拟发布:版本递增、状态流转、登记场景与标签">
<entryCriterion id="ec_s5" sentryRef="s_draft_approved"/>
</humanTask>
<!-- ⑤(后半)发布确认:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="发布确认放行">
<entryCriterion id="ec_rel" sentryRef="s_publish_ready"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要生产哪份对外内容资产,产完把它审核发布**。
入参五个顶层字段:
· \`trigger\`——**必有**,\`"proactive"\`(主动触发)或 \`"reactive"\`(被动触发·使用方请求),
翻成人读标签写进 \`content_brand.verdict.trigger\`(报告上不摆系统内英文码)。
· \`reason\`——仅 \`trigger:"proactive"\` 时可能有,四种之一:\`"strategy_shift"\`(战略调整)/
\`"new_market"\`(进入新市场)/ \`"tool_outdated"\`(工具过期)/ \`"monthly_update"\`
(月度定期内容更新)。
· \`requester\`——仅 \`trigger:"reactive"\` 时可能有,四种之一:\`"E04"\`(关系覆盖需要价值
传递素材)/ \`"T03"\`(触达需要材料)/ \`"T04"\`(攻坚需要定制内容)/ \`"E03"\`(方案需要支撑)——
T03/E03 在本仓库尚未实现,只是描述来源,不必真去调它们。
\`reason\`/\`requester\` 二选一有值,翻成人读标签一起写进
\`content_brand.verdict.trigger_detail\`(对照表见下方 S1 的 guide)。
· \`targetCustomer\`——选填,定向客户的编码(如为特定客户定制素材);通用内容留空。
· \`note\`——本轮背景说明(选填),帮你判断内容侧重与紧迫程度。
**成品是三样东西,缺一不可**:①一份**核对过的内容需求清单**;②一份**经审核通过的
内容初稿**(多语言,按 BT 层级定制结构);③**写入 L-05 品牌内容库的正式发布记录**
(版本号、状态流转、场景与 BT 标签登记齐全)。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别需求、采集市场依据、
草拟初稿、拟发布,都是你的活;核对需求清单、审核初稿、签字发布,三道都要人过。
**没有物理执行环节**:与 E04 不同,I01 没有「AI 做不到、必须人去线下办」的步骤——
内容初稿由你草拟,人只审核通过或驳回,不亲自动笔、不用到场。
**五步走**:识别需求 → 人核对 → 采集依据+草拟初稿(一步) → 人审核(可能来回改一轮)→
拟发布 → 人签字放行。
【这一单唯一的岔路口 —— 内容初稿审核,长这样,无论哪一拍都照这个发,别自己另编一张】
审核不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_draft_review
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`draft_approved_total=1\`,把这一轮草拟的每一行
\`content.asset\` 的 \`content_status\` 改成 \`reviewed\`(同 PK 覆盖写),finish 这一项,
别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`content.asset\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_draft_review","allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【提交放行之前,先把三条裁量判据裁掉 —— 同一串 judge×3 + submit】
这三条机器判不了,**没裁过就提交必被拒**(拒回会点名是哪三条):
· "版本号与状态是否正确:版本号已更新、状态流转无误"
· "场景与 BT 标签是否登记完整"
· "多语言版本是否齐全"
criterion 照上面整句原样抄;裁成 satisfied=false 照样提交得了 —— 理由连着卡交给签字的人。
**这条写在这儿而不是只写在 T_release 的剧本里**:拟发布与提交常被压成同一拍,
那一拍摊开的是拟发布那一步的做法,T_release 的剧本这时看不见 —— 说明书每拍都在。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几条需求、初稿写了几份、发布准备好了什么;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 7-9 拍就该收口**(比常路多两道人环:需求核对 + 初稿审核 + 放行,共三次;
审核若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-05 正式版 ——
这一轮生产的内容资产正式上线,可供 E04/T03/T04/E03 调用(分发追踪与复盘迭代是
独立触发的另一条时间轴,不在这一单里)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 三处不给 `doneWhen`:`S2_requirement_confirm`(交人核对触达世界)、`S4_draft_review`
* (要人拍板,且这一项内部可能循环)、`T_release`(签下去触达世界)——
* 给了等于给引擎一把替人签字的钥匙。
*/
export const CONTENT_STEPS = [
{
item: 'S1_identify_requirement',
intent: '识别本轮内容需求来源与类型,铸造本轮案号',
writes: ['content_brand.requirement', 'content_brand.verdict'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 被动触发那几条路全靠这五个字段 */
reads: ['trigger', 'reason', 'requester', 'targetCustomer', 'note', 'content.asset'],
view: 'requirement-list',
tools: [],
doneWhen: 'content_brand_requirement_rows > 0',
guide: `第一件事:把这一轮**内容需求**列出来。
【先铸造本轮案号 round_ref】格式固定:\`'CTB-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(引擎注入的开案时刻,你不必也不许自己编)。**整单只有一个 round_ref**,写
\`content_brand.requirement\` / \`.verdict\` 时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【识别需求】读 \`content.asset\`(已有资产,判断是否已有类似内容、避免重复):
· \`trigger:"proactive"\`——按 \`reason\` 映射:\`strategy_shift\`(战略调整)/
\`new_market\`(进入新市场)通常对应"公司介绍"/"能力声明"这类通用内容;
\`tool_outdated\`(工具过期)对应更新既有工具包;\`monthly_update\`(月度定期更新)
对应例行内容刷新,可参考已有资产里哪些版本号最旧、最久未更新。
· \`trigger:"reactive"\`——按 \`requester\` 映射到内容类型:\`E04\` 请求价值传递素材
→系统方案白皮书/案例集;\`T03\` 触达需要材料→销售工具包/选型指南;\`T04\` 攻坚需要
定制内容→系统方案白皮书(定向 \`targetCustomer\`);\`E03\` 方案需要支撑→技术规格书/
替代对照表。\`targetCustomer\` 有值就把这条需求的 \`target_customer\` 填成它。
一轮可以识别出不止一条需求(如月度更新可能同时刷新两三份内容)。
【写 \`content_brand.requirement\`,一条需求一行】(rows 收数组)
round_ref、req_seq(**必填**,本轮内从 1 递增)、content_type(**必填**,如
"系统方案白皮书"/"公司介绍"/"能力声明"/"销售工具包"/"案例集"/"品牌调性标准"/
"方案模板")、source(选填,一句需求来源,如"使用方请求(E04 价值传递)"或
"战略调整(进入俄罗斯市场)")、target_customer(选填,定向客户;通用内容写"—(通用)")、
scene(选填,**写人读标签**"首触"/"方案"/"攻坚"/"展会"/"报价"之一,按用途判断——这一列
在编辑面上是普通格子、不是徽标,不带翻译层,写 ASCII 码人看到的就是 ASCII 码)、
priority(选填,ASCII \`high\`/\`medium\`/\`low\`,按紧迫程度判断)、origin(固定写 \`ai\`)。
【同一拍写 \`content_brand.verdict\`(一行)】round_ref、trigger(**翻成人读标签写,
不写入参那个英文码**——\`"proactive"\`→"主动触发"、\`"reactive"\`→"被动触发")、
trigger_detail(把 \`reason\` 或 \`requester\` 也翻成人读标签写进来——
\`strategy_shift\`→"战略调整"、\`new_market\`→"进入新市场"、\`tool_outdated\`→"工具过期"、
\`monthly_update\`→"月度定期内容更新"、\`E04\`→"E04 关系覆盖需要价值传递素材"、
\`T03\`→"T03 触达需要材料"、\`T04\`→"T04 攻坚需要定制内容"、\`E03\`→"E03 方案需要支撑")、
target_customer(入参原样抄,没有留空)、note(入参原样抄,没有留空)、summary 一句人话
(本轮识别到几条需求、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_requirement_confirm',
intent: '交人核对需求清单:增删改,确认本轮真正要生产什么',
writes: ['content_brand.verdict'],
reads: ['content_brand.requirement'],
view: 'requirement-list',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `需求清单起草好了,**该内容运营核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是需求清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 规则之外的
目标客户、适用场景、优先级、定制需求(如使用方要一份方案,他线下知道要针对某客户
做定向版)。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_requirement_confirm","workOrder":"execution","card":{
"title":"需求受理:本轮共 <N> 条内容需求",
"ask":"AI 已按规则识别出 <N> 条内容需求,请核对确认。",
"steps":["工作区的件上核对需求清单:内容类型是必填,缺一条这一单走不到内容创作那一步",
"加一行 = 补充新需求(AI 规则之外的目标客户/场景/优先级/定制)",
"删一行 = 排除(AI 识别但实际不需要生产的需求)",
"改单元格 = 修正 AI 识别的内容 —— 尤其是目标客户、适用场景、优先级",
"改完保存,回这张卡提交"],
"standard":"需求清单里每一行内容类型都齐;该补充的都补充、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 content_brand.verdict 刚写的 trigger/trigger_detail 说,
如「主动触发:月度定期内容更新」或「被动触发:E04 关系覆盖需要价值传递素材」>"},
{"k":"AI 识别","v":"<N> 条内容需求"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`content_brand.verdict.requirement_confirmed_total = 1\`;他在件上撤掉了行或加了行
的话,下一步的市场依据采集与内容创作按当下 \`content_brand.requirement\` 的实际行数走,
不必你重写。
② finish 这一项。**别在这一拍采集市场依据或草拟初稿** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时内容类型一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_gather_and_draft',
intent: '采集市场依据、按 BT 层级草拟多语言内容初稿',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content_brand.requirement', 'market.signal', 'content.asset'],
view: 'draft-review',
tools: [],
doneWhen: 'content_asset_rows > 0 and content_asset_missing = 0',
guide: `需求确认了,**现在读市场依据、草拟内容初稿** —— 一拍连做,这一步纯加工态,
不必找人。
【采集市场依据】读 \`market.signal\`(L-01 宏观市场台账):按需求里的 \`target_customer\`/
业务背景筛选相关的 \`market_region\`/\`industry\`/\`chip_category\` 信号,归纳产业趋势与
差异化定位。**读不到相关信号是正常结果**(不是每条内容需求都有现成的市场信号支持)——
读不到就如实基于已有资产与客户信息拟定,别在报告里编一条不存在的市场依据。
【按 BT 层级草拟内容初稿】逐条 \`content_brand.requirement\` 草拟一份初稿:
· BT-01(采购/供应链层)侧重:供货能力 / 资质文件 / 渠道政策;
· BT-02(技术层)侧重:技术规格书 / 替代对照表 / 认证报告;
· BT-03(高层)侧重:系统方案白皮书 / 参考设计 / 成功案例。
需求没指明 BT 层级的,按内容类型判断("系统方案白皮书"多半是 BT-03,"技术规格书"多半
是 BT-02)。**维护多语言版本**:中文必备,俄罗斯市场相关(俄语区客户/进入俄罗斯市场
的需求)加俄语,其余按需求背景判断要不要加英语。同一份内容的多语言版本写进**同一行**
的 \`language\` 列(用" · "连接,如"中文 · Русский"),不必为每种语言各开一行。
读 \`content.asset\` 里已有资产与品牌调性标准,保持措辞一致、避免重复生产同一份内容。
【写 \`content.asset\`,一份内容初稿一行】(rows 收数组)
content_id(**必填**,格式固定 \`'CT-' + 本年年份 + '-' + 本轮内四位序号\`,如
"CT-2026-0001"、"CT-2026-0002",本轮内递增、不与已有资产的编号相撞)、content_type
(**必填**,照抄需求的 content_type)、title(**必填**,具体标题,不写"内容初稿"这类
占位词)、language(**必填**,如"中文"或"中文 · Русский")、version(**必填**,本步一律
写 \`v0.1\`)、scene(选填,照抄需求的 scene 人读标签)、content_status(固定写 \`draft\`)、
brand_tone(选填,按品牌调性标准挑 1-2 个最贴合的,如"专业 · 可信赖")、bt_tag(选填,
**写人读值** "BT-01"/"BT-02"/"BT-03" 之一,按上面那三档判 —— 这一列在件上是普通格子、
不带翻译层,写 ASCII 码人看到的就是 ASCII 码;验收工单要人核「BT 标签登记完整」,
核的就是它)、target_customer(选填,照抄需求的 target_customer)、file_ref(选填,
留空即可)、reviewer(选填,本步留空,S4 审核通过后再填)。
【**正文与格式:这一步真正要产出的资料**】(\`body_md\` 与 \`file_format\`,两列都**必填**)
人在下一步要审的是**资料本身**,不是一行标题 —— 件上点资料名就打开这份文件,
所以正文得真写出来。
· \`body_md\` = 这份资料的**全文,Markdown 原文**。照 BT 层级该有的结构写实内容:
一级标题一句话说清这是什么,再按"##"分节。**每一节都要有实打实的内容** ——
一句"回头再补"或者把人指去别处的话,等于这一份资料没产出。认得的写法就这几样,够用:
"# 标题" / "## 小节" / 段落 / "- 要点" / "1. 步骤" / "| 列 | 列 |" 表格 / "---" 分隔线 /
"**重点**"。多语言版本写进同一份正文的不同小节
(如 "## Русская версия"),不为每种语言另开一行。
· \`file_format\` = 这份资料**该以哪种格式交出去**,四选一,按内容形态挑,别一律写 md:
- \`xlsx\`:主体是表 —— 替代对照表、参数对比表、价格/交期清单;
- \`docx\`:正式文档 —— 系统方案白皮书、技术规格书、认证报告、渠道政策;
- \`html\`:要在网页上直接看的 —— 落地页文案、案例展示页;
- \`md\`:短说明、要点清单、内部速记。
挑 \`xlsx\` 的那一份,正文里**必须真有一张 Markdown 表格**(否则导出来是一张只有
标题的空表)。同一轮里几份内容各按自己的形态挑,不必统一。
【同一拍写 \`content_brand.verdict\`】summary 补一句人话(本轮草拟了几份内容、依据是
什么市场信号或已有资产)。
写完这一拍就结束(不必你 finish)——\`content_asset_rows > 0 and content_asset_missing = 0\`
一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_draft_review',
intent: '内容初稿审核:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset'],
view: 'draft-review',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 把这一轮 \`content.asset\` 里状态还是 \`draft\` 的每一行
改成 \`content_status = "reviewed"\`(同 PK 覆盖写,\`reviewer\` 补一句"AM Lead 审核通过"),
写 \`content_brand.verdict.draft_approved_total = 1\`,finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订初稿再重新发卡。
【这一步办完了没有,看一个数】\`draft_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_draft_review",
"allowCustom":true,
"ask":"内容初稿已草拟,共 <N> 份,请审核。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"内容进入发布准备阶段"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按调性标准复核一遍初稿,重新提交审核"}],
"basis":[{"k":"本轮初稿","v":"<N> 份,覆盖 <涉及的内容类型>"},
{"k":"调性依据","v":"专业 / 可信赖 / 高效 / 本土化"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`content.asset\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:对照调性标准
(专业/可信赖/高效/本土化)重新审视措辞,技术数据有没有可疑处;复核后**至少要有
一处实质性变化**(改措辞、补细节、调标签,不能原样重发)。
③ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了措辞、补充了认证细节)请再次审核"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
④ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_publish_prep',
intent: '拟发布:版本号递增、状态流转、登记场景与 BT 标签',
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
doneWhen: 'publish_prepared_total > 0 and content_asset_content_status_published > 0 and content_asset_missing = 0',
guide: `审核通过了,**现在拟发布** —— 一拍连做。
【写 \`content.asset\`,把审核通过的每一行(\`content_status = "reviewed"\`)改成拟发布态】
(同 PK 覆盖写)version(**必填**,由 \`v0.1\` 改成 \`v1.0\`;若这份内容是已发布内容的
修订版本(\`content.asset\` 里已有同标题的 \`published\` 行),小版本递增,如 \`v1.1\`)、
content_status(**必填**,固定写 \`published\`)、scene(**必填**,若上一步没填齐,这一步
补上 —— 发布前场景必须登记完整)、bt_tag(**必填**,同上,人读值 "BT-01"/"BT-02"/"BT-03";
场景与 BT 标签这两列**声明里钉的就是「发布态才必填」**,状态一写成 published,
它们空着就会被红带、提交闸、放行门 L2 同时拦下)、brand_tone(选填,若上一步没填,
这一步按调性标准补)。
**这一步只是把状态写进本单草稿层,不是真的对外发布** —— 铁律②:草稿层的变更要等
T_release 人签字放行才合并进 L-05 正式版、真正对外可见。
【同一拍写 \`content_brand.verdict\`】publish_prepared_total = 1、
content_produced_total = 这一轮拟发布的内容份数、summary 补一句人话(这一轮生产了
哪几类内容、准备发布几份)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '发布已准备好,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这两张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['content.asset', 'content_brand.verdict'],
reads: ['content.asset', 'content_brand.requirement'],
view: 'publish-confirm',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `拟发布内容齐了、必填也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**版本号/状态不对** → 改 \`content.asset\` 的 \`version\`/\`content_status\`(同 PK 覆盖写);
· 他说**场景或 BT 标签没登记全** → 补 \`scene\`/\`bt_tag\`;
· 他说**语言版本不齐** → 补 \`language\`(同一行用" · "连接,不新开行);
· 他说**标题/调性不合** → 改 \`title\`/\`brand_tone\`;
· 他**没写具体意见**(只点了重做)→ 自己把这几份内容复核一遍,挑最站不住的那处改实,
**至少改对一处**;实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是行**:同一行覆盖写,别新开行 —— 那会把收步判据与发布份数弄假。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="版本号与状态是否正确:版本号已更新、状态流转无误" —— 逐条看
\`content.asset\` 的 \`version\`/\`content_status\`:版本号已从 \`v0.1\` 更新、状态流转
(草稿 → 已审核 → 已发布)每一步都走过没有跳步,就 satisfied=true;有一条对不上,
satisfied=**false**,rationale 逐条写清,交签字的人定。
· judge criterion="场景与 BT 标签是否登记完整" —— 核对每条拟发布内容的 \`scene\`/
\`bt_tag\` 是否都有值(这两列在声明里是「发布态必填」,基座也数得出来;
你要判的是**登记得对不对**,不只是有没有值)。
· judge criterion="多语言版本是否齐全" —— 核对 \`language\` 是否覆盖了这条内容该有的
语言版本(如面向俄罗斯市场的内容至少要有俄语)。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`content-published\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/3。
*/
export const CONTENT_PHASES = [
{ name: '需求识别与受理', items: ['S1_identify_requirement', 'S2_requirement_confirm'] },
{ name: '采集与创作', items: ['S3_gather_and_draft'] },
{ name: '审核与发布', items: ['S4_draft_review', 'S5_publish_prep'] },
];
@@ -0,0 +1,11 @@
/**
* I01 · 品牌形象与销售工具包管理 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 三条技能 + 台账声明 + 三张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'brand-content-toolkit' });
@@ -0,0 +1,526 @@
/**
* 活动包清单 —— I01 品牌形象与销售工具包管理(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/i01-html/I01-活动流程与工单说明.md`(7 步业务链条,
* 工单只针对生产流程①→⑤,收成 1 张执行工单 + 2 张验收工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-01 宏观市场台账 /
* L-05 品牌内容库的字段表与读写关系)。
*
* 这是本仓库第二个不属于 `quote-*` A1-A7 报价管线的活动,方法论照抄第一个
* (`crm-relationship-coverage` / E04)已验证过的样子:双级下拉入参、活动自有工作台账
* + 共享主数据、decide 决策卡循环做中间验收、六步案例模型。
*
* 两处与 E04 的刻意不同:
* ① E04 核心是**读写**共享主数据(`customer.profile`/`customer.contact`),
* I01 核心是**只读** L-01(`market.signal`)、**读写**它独有的 L-05
* (`content.asset`)——本 Demo 范围内 I01 是 `content.asset` 唯一的读写者,
* 不追加列合并,是这份活动包**第一次**完整声明这两张台账的物理结构。
* ② I01 没有 E04 那种「AI 做不到、必须人去线下执行」的环节(内容初稿由 AI 草拟,
* 人只审不做)——所以只有 1 张执行工单(S2_requirement_confirm),
* 验收工单反倒有 2 张(S4_draft_review 审初稿 + T_release 发布确认),
* 与 E04(1 张验收决策 + 1 张终局放行)结构同型,只是执行工单少一张。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { CONTENT_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, CONTENT_PHASES, CONTENT_STEPS } from './case-model.ts';
import { REQUIREMENT_LIST, DRAFT_REVIEW, PUBLISH_CONFIRM } from './views/report.ts';
const D = 'content_brand';
const ACTIVITY_ID = activityIdSchema.parse('brand-content-toolkit');
/** 没有判定表 —— 触发事件→内容类型、BT 层级→内容结构都是文字规则,不是离散分支命中表。 */
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'I01 · 品牌形象与销售工具包管理',
packageVersion: '1.2.2',
category: '内容生产',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-brand-content-toolkit',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数 —— 与 E04 同一处置
* (两级下拉 + 选填客户/说明):
* ① `trigger` 必填 —— 主动 / 被动,显式下拉,不接受省略;
* ② `reason`/`requester` 选填,只在对应 `trigger` 值时现身(`showWhen`);
* ③ `targetCustomer` 选填字符串 —— 定向客户编码,通用内容留空。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['proactive', 'reactive'],
enumNames: ['主动触发(战略调整/新市场/工具过期/月度更新)', '被动触发(使用方请求)'],
title: '触发方式',
},
reason: {
type: 'string',
enum: ['strategy_shift', 'new_market', 'tool_outdated', 'monthly_update'],
enumNames: ['战略调整', '进入新市场', '工具过期', '月度定期内容更新'],
title: '主动触发的原因(仅主动触发时选)',
showWhen: { field: 'trigger', equals: 'proactive' },
},
requester: {
type: 'string',
enum: ['E04', 'T03', 'T04', 'E03'],
enumNames: [
'E04 关系覆盖需要价值传递素材',
'T03 触达需要材料',
'T04 攻坚需要定制内容',
'E03 方案需要支撑',
],
title: '被动触发的使用方(仅被动触发时选)',
showWhen: { field: 'trigger', equals: 'reactive' },
},
targetCustomer: {
type: 'string',
title: '定向客户编码(选填,为特定客户定制素材时填;通用内容留空)',
examples: ['CU-0142'],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断内容侧重与紧迫程度)',
examples: ['进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用'],
},
},
},
messageAllowed: true,
messageHint: '内容运营可在此说明本轮生产的特殊要求、目标受众、或需要重点突出的内容侧重',
},
output: {
/* 单一必发口,无分支:I01 生产流程线性(①→②③→④→⑤),没有岔路口。 */
mandatory: [
{
name: 'content-published',
description:
'本轮已发布的内容资产 —— 供 E04/T03/T04/E03 分发调用;当前代码库尚未实现这些下游活动的读取消费者,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'publishedContentTotal'],
properties: {
roundRef: { type: 'string', title: '本轮案号' },
publishedContentTotal: { type: 'integer', title: '本轮已发布内容份数' },
},
},
},
],
selective: [],
},
/*
* 三份必交付物,逐一对应三张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(内容初稿与拟发布明细都是 AI 的结论,
* 人只做通过/驳回或签字,不在验收页上就地改)。
*/
requiredDeliverables: [
{
key: 'requirement-confirm',
label: '内容需求清单',
ledger: `${D}.requirement`,
ledgers: [`${D}.requirement`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'draft-review', label: '内容初稿', ledger: 'content.asset', editableAtRelease: [] },
{ key: 'publish-confirm', label: '拟发布内容', ledger: 'content.asset', editableAtRelease: [] },
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing`/按值计数基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(版本状态对不对、标签齐不齐、
* 多语言够不够)——机器数得出「有没有值」,数不出「对不对、够不够」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半靠记账旗(只有人回卡之后模型才记 —— 与 A1 的
* confirmed_total 同款,规范 §5.3-4 背书的人环凭据);但**每条都必须再钉一半
* 基座数**(2026-08-23 照 E04 的账整改):旗是模型写的,单独成谓词就是
* 「机检只读基座数」第 N 次违反的伪装形 —— E04 那单抢跑写旗、活单熔断,
* 正是踩着这半边过的门。
*/
{
key: 'requirement-confirmed',
label: '内容需求已经内容运营核对确认(执行工单已交回,清单有行且内容类型齐)',
level: 'L1',
predicate: `requirement_confirmed_total > 0 and ${D}_requirement_rows > 0 and ${D}_requirement_missing = 0`,
},
{
key: 'draft-approved',
label: '内容初稿已经 AM Lead 审核通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: 'draft_approved_total > 0 and content_asset_rows > 0 and content_asset_missing = 0',
},
{
key: 'draft-complete',
label: '内容初稿逐份完整:标题、内容类型、语言版本、状态都有',
level: 'L2',
predicate: 'content_asset_rows > 0 and content_asset_missing = 0',
},
/*
* **资料文件真产出了没有** —— 两边都是基座现数:每一行都报了一种格式
* (四档计数之和 = 行数),而正文空着的行会被 `_missing` 数出来(body_md 是必填)。
* 从前这一步只产出一行标题,人点不开也下不来 —— 判据得能指向一个看得见的东西:
* 这一条为真,件上每个资料名都点得开。
*/
{
key: 'asset-file-ready',
label: '每份内容都产出了资料文件(格式已定、正文已写,件上点资料名即可打开)',
level: 'L2',
predicate:
'content_asset_rows > 0 and content_asset_missing = 0 and ' +
'content_asset_file_format_md + content_asset_file_format_html + ' +
'content_asset_file_format_docx + content_asset_file_format_xlsx = content_asset_rows',
},
{
key: 'publish-registered',
label: '拟发布内容已登记为已发布状态且逐项完整',
level: 'L2',
predicate: 'content_asset_content_status_published > 0 and content_asset_missing = 0',
},
{ key: 'version-status-correct', label: '版本号与状态是否正确:版本号已更新、状态流转无误', level: 'L4' },
{ key: 'scene-tag-complete', label: '场景与 BT 标签是否登记完整', level: 'L4' },
{ key: 'language-complete', label: '多语言版本是否齐全', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '发布入库放行:签下去即正式生效并写入 L-05', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-fabricate-signal', statement: '市场信号读不到相关内容时不得编造 —— 如实基于已有资产与客户信息拟定' },
{ key: 'no-skip-review', statement: '内容状态流转不得跳步 —— draft → reviewed → published 每一步都要真的写过' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/* 基线三次(S2/S4通过/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次,+4),留够余量 */
humanCallBudget: 10,
},
resources: {
tools: [],
skills: CONTENT_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
budget: { steps: 60, tokens: 400_000, activeMinutes: 60, humanCalls: 10 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [],
ledger: [
/*
* ── L-01 宏观市场台账(kind:'master',I01 只读)──
* 首次完整声明:本仓库尚无其它活动读写它,不是追加列。
*/
{
domain: 'market',
object: 'signal',
kind: 'master',
label: '宏观市场台账',
fields: [
{ name: 'signal_id', type: 'text', primaryKey: true, masked: false, label: '信号ID' },
{
name: 'signal_type',
type: 'text',
primaryKey: false,
masked: false,
tally: ['macro_economy', 'industry_trend', 'competition', 'policy', 'customer_market'],
label: '信号类型',
},
{ name: 'title', type: 'text', primaryKey: false, masked: false, label: '标题' },
{ name: 'content', type: 'text', primaryKey: false, masked: false, label: '详情' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, label: '信号来源' },
{ name: 'signal_date', type: 'date', primaryKey: false, masked: false, label: '信号日期' },
{ name: 'market_region', type: 'text', primaryKey: false, masked: false, label: '所属市场' },
{ name: 'industry', type: 'text', primaryKey: false, masked: false, label: '细分行业' },
{ name: 'chip_category', type: 'text', primaryKey: false, masked: false, label: '关联芯片品类' },
{ name: 'importance', type: 'integer', primaryKey: false, masked: false, label: '重要性评分(1-5)' },
{
name: 'impact_dir',
type: 'text',
primaryKey: false,
masked: false,
tally: ['opportunity', 'threat', 'neutral'],
label: '影响方向',
},
{
name: 'signal_status',
type: 'text',
primaryKey: false,
masked: false,
tally: ['pending', 'evaluated', 'archived', 'expired'],
label: '信号状态',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, label: '版本号' },
{ name: 'review_note', type: 'text', primaryKey: false, masked: false, label: '复盘备注' },
],
},
/*
* ── L-05 品牌内容库(kind:'master',I01 唯一读写者)──
* 首次完整声明并持有全部基本列(不是追加,`declarations.ledger` 里明写 required/expect,
* 因为没有别的活动会与 I01 在这张表上"谁先立谁的字段"打架)。
*/
{
domain: 'content',
object: 'asset',
kind: 'master',
label: '品牌内容库',
fields: [
{ name: 'content_id', type: 'text', primaryKey: true, masked: false, label: '内容ID' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'title', type: 'text', primaryKey: false, masked: false, required: true, label: '内容标题' },
{
name: 'language',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '语言版本(多语言用" · "连接)',
},
{ name: 'version', type: 'text', primaryKey: false, masked: false, required: true, label: '版本号' },
/*
* **发布那一刻才必填**(2026-08-23):S5 的剧本明写「发布前场景必须登记」,
* 而声明只标 expect —— 机器数不出来,那句话就只能靠 L4 让人肉眼核。
* `requiredWhen` 把必填钉在**已发布**的那几行:草稿态不拦(那时还没定场景),
* 一旦状态流到 published,红带/提交闸/放行门 L2 三处同源同数地拦。
*/
{
name: 'scene',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: '适用场景',
},
/*
* BT 标签 —— 目标件的验收工单1 就有这一列,而从前 `content.asset` 压根没有它:
* L4 判据「场景与 BT 标签是否登记完整」让人核一个卡面上不存在的东西
* (判据指向的东西必须摆得出来,E04 那一课)。与 `scene` 同款:发布那一刻才必填。
*
* **不带 tally** —— 与 `requirement.scene` 同一条教训(2026-08-21):
* tally 的取值域得是 ASCII(`data-class-of` 只认 ASCII),而这一列在件上是
* 普通格子、走 `data-slot` 直显,没有翻译层 —— 存 `bt_03` 人看到的就是 `bt_03`。
* 存人读值("BT-01"/"BT-02"/"BT-03"),屏上与目标件一字不差。
*/
{
name: 'bt_tag',
type: 'text',
primaryKey: false,
masked: false,
expect: true,
requiredWhen: { column: 'content_status', value: 'published' },
label: 'BT 层级标签',
},
{
name: 'content_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['draft', 'reviewed', 'published', 'deprecated'],
label: '内容状态',
},
{ name: 'brand_tone', type: 'text', primaryKey: false, masked: false, expect: true, label: '品牌调性标签' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户版本' },
/*
* ── 一份内容 = 一份**资料文件**(2026-08-23 用户:「审核资料这一步要真实
* 产出资料文件,格式可以是 word/excel/md/html,点资料名能打开渲染」)──
*
* 从前只有一句 `file_ref: '文件位置'`,而 S3 的剧本明写「本 MVP 阶段可留空」:
* **这一步压根没产出过任何文件** —— 屏上一行标题,点不开、下不来,
* 人要审的那份初稿根本不在系统里。
*
* 现在正文落 `body_md`(Markdown 原文,AI 写的就是它),格式落 `file_format`,
* 基座按 `file` 段现造成真字节(见 `apps/api/src/app/file-compose.ts`):
* .docx 用 Word 打得开、.xlsx 用 Excel 打得开,屏上渲的就是那份字节读回来的样子。
*
* 为什么正文只写一种(Markdown)而不是四种各写一遍:四份就是四份事实,
* 改一处就分叉。**写一份,按格式投影** —— 与「文件是台账的投影」同一条。
*/
{
name: 'body_md',
type: 'text',
primaryKey: false,
masked: false,
required: true,
label: '内容正文(Markdown 原文)',
},
{
name: 'file_format',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['md', 'html', 'docx', 'xlsx'],
label: '资料文件格式',
},
{ name: 'file_ref', type: 'text', primaryKey: false, masked: false, label: '文件位置' },
{ name: 'reviewer', type: 'text', primaryKey: false, masked: false, label: '审核人' },
],
/**
* 这张表的每一行还是**一份资料文件**:标题、正文、格式各取哪一列。
* 这一段同时是授权面 —— 不声明它,件上的资料名就点不开(基座 403)。
*/
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
},
/* ── I01 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'requirement',
kind: 'record',
caseKey: 'round_ref',
label: '内容需求清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'req_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮需求序号' },
{ name: 'content_type', type: 'text', primaryKey: false, masked: false, required: true, label: '内容类型' },
{ name: 'source', type: 'text', primaryKey: false, masked: false, expect: true, label: '需求来源' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, expect: true, label: '目标客户' },
/*
* **不带 tally**(2026-08-21 实测逼出的修死):这一列是编辑面上的普通格子
* (`REQUIREMENT_TABLE` 里就是一个 `<span data-col>`,不是 `data-class-of` 徽标),
* tally 过的 ASCII 码没有翻译层就直接摆上屏——实测人看见的是裸的 "solution",
* 不是"方案"。`content.asset.scene`(S3 照抄这一列)本来就是自由文本,两处
* 现在口径一致:AI 写人读标签(首触/方案/攻坚/展会/报价),不写 ASCII 码。
*/
{ name: 'scene', type: 'text', primaryKey: false, masked: false, expect: true, label: '适用场景' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮内容生产结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '案号' },
{ name: 'trigger', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'trigger_detail', type: 'text', primaryKey: false, masked: false, label: '触发细节(人读标签)' },
{ name: 'target_customer', type: 'text', primaryKey: false, masked: false, label: '定向客户' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'requirement_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '需求已确认' },
{ name: 'draft_approved_total', type: 'integer', primaryKey: false, masked: false, label: '初稿已审核通过' },
{ name: 'publish_prepared_total', type: 'integer', primaryKey: false, masked: false, label: '发布准备已收口' },
{ name: 'content_produced_total', type: 'integer', primaryKey: false, masked: false, label: '本轮产出内容份数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮生产结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 内容生产不是紧迫单,给够时间让人审核 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: CONTENT_STEPS,
phases: CONTENT_PHASES,
},
skills: CONTENT_SKILLS,
/*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑;
* · `draft-review` —— S3/S4 摆,内容初稿,只读(业务说明明文"人只审不改");
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,只读,放行后即归档件。
*/
viewTemplates: [
{ name: 'requirement-list', html: REQUIREMENT_LIST },
{ name: 'draft-review', html: DRAFT_REVIEW },
{ name: 'publish-confirm', html: PUBLISH_CONFIRM },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮内容生产:需求识别依据、审核是否被驳回过(为什么)、市场依据来自哪些信号、发布的内容覆盖哪些场景与语言。',
},
],
},
/*
* ── 样例集 —— 三条形状真不同 ────────────────────────────────
*
* **第一条必须是满载的那条**(2026-08-23,与 E04 同一课):测试台落到一个活动上
* 就自动铺 `samples[0]`(activity-run.tsx 的 useEffect),第一条就是人打开这一屏
* 看见的那份入参。从前第一条是月度更新、只有两个键,表单大半是空的,看着像没预填。
* **样例名一律 ≤14 字**:超了测试台的样例钮只显示「样例 N」,那等于没名字。
*/
samples: [
{
name: 'E04 请求定向素材',
input: {
trigger: 'reactive',
requester: 'E04',
targetCustomer: 'CU-0142',
note: 'E04 关系覆盖与关键人经营需要一份面向 Itelma 的价值传递素材,用于下轮拜访',
},
expect: { ports: ['content-published'] },
},
{
name: '月度定期内容更新',
input: {
trigger: 'proactive',
reason: 'monthly_update',
note: '八月例行内容刷新:优先看版本最旧、最久未更新的那几份工具包',
},
expect: { ports: ['content-published'] },
},
{
name: '进入新市场 · 俄罗斯',
input: {
trigger: 'proactive',
reason: 'new_market',
note: '公司战略进入俄罗斯市场,需要一份中俄双语的公司介绍与能力声明,月底展会要用',
},
expect: { ports: ['content-published'] },
},
/*
* **一轮里三种形态各要一份** —— 这一条样例是为「资料文件」那一环常备的
* (2026-08-23):前三条要的都是成篇的文档,模型一律挑 docx 并不算错,
* 于是表格型与网页型那两条路平时走不到。这一条把三种形态摆在同一封需求里:
* 对照表是表(xlsx)、落地页是网页(html)、销售速记是短要点(md)。
*/
{
name: '对照表与落地页',
input: {
trigger: 'reactive',
requester: 'FAE 华东',
note:
'客户要一份 TI 车规 MCU 的替代料参数对照表(逐型号列封装/温度/认证/交期,做成表格发过去);' +
'同时官网要上一版车规 MCU 能力的落地页文案(网页直接看);' +
'再给销售一页随身速记,三五条要点即可。',
},
expect: { ports: ['content-published'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-brand-content-toolkit' },
};
@@ -0,0 +1,80 @@
/**
* I01 技能 —— 品牌内容生产的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const CONTENT_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('brand-content.requirement-mapping'),
label: '触发事件映射到内容需求',
applicability: '识别本轮内容需求之前,要把触发事件(主动/被动)翻成具体该产什么。',
method: [
'主动触发按 `reason` 映射:`strategy_shift`/`new_market` 多半对应通用类内容',
'(公司介绍、能力声明——面向新市场或新战略方向,受众是还不认识我们的人);',
'`tool_outdated` 对应刷新既有工具包(读 `content.asset` 找版本号最旧的那份);',
'`monthly_update` 对应例行内容盘点,不是逢月必产新内容——先看有没有真正过期的。',
'',
'被动触发按 `requester` 映射:E04(关系覆盖)要的是「价值传递素材」,多半是系统方案',
'白皮书或案例集,常常带 `targetCustomer`(定向版);T03/T04/E03 目前本仓库未实现,',
'只是描述来源,产出物按它们的业务含义(触达材料/攻坚定制内容/方案支撑)对应到',
'内容类型即可,不必真的调用它们。',
'',
'同一轮识别出的多条需求不必是同一类型 —— 一次月度更新可能同时该刷新公司介绍',
'和某个过期的选型指南,分开列成两条需求,各自的优先级、目标客户可能完全不同。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['触发事件 × 内容类型映射表'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.bt-structure'),
label: '按 BT 层级套内容结构、维护多语言',
applicability: '市场依据采集完、要草拟内容初稿时。',
method: [
'内容结构按决策链层级走,不是一刀切:BT-01(采购/供应链)要的是「能不能稳定供货」',
'——供货能力、资质文件、渠道政策;BT-02(技术层)要的是「参数对不对得上」——',
'技术规格书、替代对照表、认证报告;BT-03(高层)要的是「值不值得投入」——',
'系统方案白皮书、参考设计、成功案例。给错层级的内容(拿技术规格书糊弄高层)',
'传递不出该传递的价值,人审核这一步会驳回。',
'',
'多语言不是全都要三语俱全:中文必备;涉及俄罗斯市场或俄语区客户的内容配俄语;',
'其余按需求背景判断——面向国际展会或英语区客户才配英语,通用内部资料未必需要。',
'语言版本写进同一行的 `language` 列(如"中文 · Русский"),不是同一份内容开多行,',
'否则发布确认那一步的验收判据③(多语言版本是否齐全)没法在一行里核对齐不齐。',
'',
'市场依据读不到相关信号是正常结果,不是异常——不是每条需求都有现成的产业趋势',
'支持,读不到就基于已有资产与客户信息拟定,报告里如实说依据来自哪里,不编一条',
'不存在的市场信号撑场面。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['BT 层级内容结构', '多语言版本策略'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('brand-content.publish-flow'),
label: '版本受控、状态流转不跳步',
applicability: '审核通过之后,要把内容拟发布、准备交人签字放行时。',
method: [
'状态流转是台阶,不是开关:`draft`(草稿)→`reviewed`(已审核)→`published`(已发布),',
'每一步都要真的走过——审核通过那一刻先落 `reviewed`,拟发布那一步才落 `published`,',
'不要图省事在审核通过的同一拍就直接写成 `published`(那样发布确认那一步的判据①',
'核不出「状态流转无误」,因为 `reviewed` 这一站压根没被记录过)。',
'',
'版本号只在真正定稿时才递增到 v1.0——v0.x 是草稿阶段的内部编号,不对外。',
'同一份内容之后若再修订(如客户反馈需要调整),版本号在已发布版本基础上加小版本',
'(v1.0 → v1.1),不要退回 v0.x 或另起 content_id——同一份内容资产的版本历史',
'要能连起来看,另起编号等于假装这是一份全新的内容。',
'',
'场景与 BT 标签是发布前必须补齐的两列,不是可选的元数据——它们是 E04/T03/T04',
'将来按场景/BT 层级匹配调用这份内容时唯一的检索依据,缺了这两列,发布出去的内容',
'实际上没人找得到、等于白产。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['内容状态机', '版本号递增规则'],
version: '1.0.0',
},
];
@@ -0,0 +1,163 @@
/**
* I01 品牌形象与销售工具包管理 · 视觉底(**I01 自己的 PRD 目标件**)。
*
* `docs/SS01-客户与市场/i01-html/` 下的三份 HTML(执行工单 1、验收工单 1/2)逐字量出。
* 颜色变量、卡头渐变、判据框、表格排版与 E04(`crm-relationship-coverage`)的目标件
* **逐字节相同**——两份目标件本就是同一套设计语言产出的(SS01 客户与市场同一批交付),
* 不是巧合也不是抄近路;`activities/*` 只准 import `@sdk`/`@contracts`,两个活动包之间
* 没有代码通道,只能各自持有一份(与 E04 那份原是 A1 家族版面拷贝、后改为自有目标件同理)。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些"数据驱动的开关",判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
/*
* 资料名 = 可点开的那一格(2026-08-23)。**看得出能点**才会有人点:
* 品牌蓝 + 下划虚线 + 手型;悬停实线。点了去哪由外层接(件里一行脚本也没有)。
*/
.proto-root .jc-fileln { color: var(--brand); border-bottom: 1px dashed var(--brand-soft); cursor: pointer; transition: all var(--t-fast); }
.proto-root .jc-fileln:hover { border-bottom-style: solid; background: var(--brand-wash); }
/* 空标题不该长得像能点开 —— 那一格根本没有资料 */
.proto-root .jc-fileln:empty { border-bottom: 0; cursor: default; background: none; }
/* 格式徽:摆的是这份资料交出去时的样子(md / html / docx / xlsx) */
.proto-root .jc-fmt { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 19px;
padding: 1px 7px; border-radius: 5px; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
background: var(--soft); color: var(--ink-3); border: 1px solid var(--line); }
.proto-root .jc-fmt.v-docx { background: #eaf1ff; color: #2b5fd9; border-color: #c3d6ff; }
.proto-root .jc-fmt.v-xlsx { background: #e9f7ee; color: #1f7a45; border-color: #bfe6cd; }
.proto-root .jc-fmt.v-html { background: #fdf0e6; color: #b25e15; border-color: #f3d3b4; }
.proto-root .jc-fmt.v-md { background: #f2f3f5; color: #5a6068; border-color: #dfe1e5; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件三份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,387 @@
/**
* I01 品牌形象与销售工具包管理的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 三个局面各一张:
* · `requirement-list` —— S1/S2 摆,内容需求清单,可编辑(增删改);
* · `draft-review` —— S3/S4 摆,内容初稿,**只读**(业务说明明文"人只审不改" ——
* 通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `publish-confirm` —— S5/T_release 摆,拟发布内容,**只读**,放行后即归档件。
*
* 版面语言逐字量自 I01 自己的目标件(`docs/SS01-客户与市场/i01-html/` 三份 HTML,
* `prd-style.ts` 头注写了它与 E04 目标件"逐字节相同"的原因)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:content_brand.<object>` 或
* `mine:content.asset`;列名逐字照 `declarations.ledger` 抄。**模版不算不判** ——
* 缺必填红带、状态徽、排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'content_brand';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
/* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件 */
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown 认) */
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown) td { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel { display: block; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
/*
* 统一的枚举标签(优先级 / 内容状态 共用同一套机制):值是机器码,人读名与颜色一律由
* data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-draft::after { content: "草稿"; }
.proto-root .jc-tag.v-draft { color: var(--ink-3); }
.proto-root .jc-tag.v-reviewed::after { content: "已审核"; }
.proto-root .jc-tag.v-reviewed { color: var(--warn-accent); }
.proto-root .jc-tag.v-published::after { content: "已发布"; }
.proto-root .jc-tag.v-published { color: var(--ok); }
.proto-root .jc-tag.v-deprecated::after { content: "已废弃"; }
.proto-root .jc-tag.v-deprecated { color: var(--ink-4); text-decoration: line-through; }
`;
/** 三层壳照目标件:少一层样式表全失效 */
/**
* 三层壳照目标件。卡头/页脚的共用动态位照说明书 §6.2 补齐(2026-08-23):
* 收件人(case:owner)、**关联任务**(case:taskId,基座注入)、**步骤**(step,
* 每份工单盖哪几步是写清单时定死的静态文案)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做
* (与 E04 同一口径)。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 目标件 side 第二行:静态步骤文案 */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<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>
<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">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(照目标件:「AI 已识别 N 个内容需求,点「编辑」增删改」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 内容需求清单(S1/S2)==================== */
const REQUIREMENT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th style="width:56px;min-width:56px">序</th>
<th class="req" style="min-width:150px">内容类型</th>
<th class="sug" style="min-width:170px">需求来源</th>
<th class="sug" style="min-width:120px">目标客户</th>
<th class="sug" style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">优先级</th>
</tr></thead>
<tbody data-rows="mine:${D}.requirement" data-edit="${D}.requirement"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.requirement" title="撤掉这一行">×</button></td>
<td data-h="序"><span class="jc-vcell mono" data-slot="req_seq" data-col="req_seq" data-blank></span></td>
<td class="req" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-col="content_type" data-blank></span></td>
<td class="sug" data-h="需求来源"><span class="jc-vcell" data-slot="source" data-col="source" data-blank></span></td>
<td class="sug" data-h="目标客户"><span class="jc-vcell" data-slot="target_customer" data-col="target_customer" data-blank></span></td>
<td class="sug" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-col="scene" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
</tr></tbody>
</table></div>`;
export const REQUIREMENT_LIST = doc({
tone: 'human',
badge: '执行',
tag: '执行工单',
kicker: 'I01 品牌形象与销售工具包管理 · 执行工单 1/3',
title: '需求受理与信息补充',
titleRef: 'round_ref',
step: '步骤 ① 需求识别与受理',
state: `<span class="jc-rp-wait">共 <b data-slot="${D}_requirement_rows"></b> 条需求</span>`,
body:
part('做什么', '确认本轮要生产的内容') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按规则识别本轮内容需求来源(战略调整 / 新市场 / 工具过期 / 使用方请求)与
需求类型(公司介绍 / 能力声明 / 销售工具包 / 案例集 / 白皮书等),产出下方需求清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger" data-blank></b> <b data-slot="trigger_detail" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」在清单上增删改 —— <b>加一行 = 补充新需求</b>、
<b>删一行 = 排除</b>、<b>改单元格 = 修正</b>;尤其要补「目标客户、适用场景、优先级」这些 AI 规则之外的信息。</p>
</div>` +
part('交回什么', '核对后的内容需求清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_requirement_missing">
<span><b>还差 <span data-slot="${D}_requirement_missing"></span> 行内容类型没填</b>
—— <span data-slot="${D}_requirement_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.requirement`,
`<b>AI 已识别 <span data-slot="${D}_requirement_rows"></span> 个内容需求</b>,点「编辑」增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 补充新需求,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>内容类型</b>是必填,缺一条这一单走不到内容创作那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
REQUIREMENT_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.requirement">+ 加一行(补充新需求)</button>
<em>新加行的「内容类型」为必填;「目标客户」填定向客户,通用内容可留空;<b>序号留空即可</b> —— 系统按本轮现有最大值往下续。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('I01 品牌形象与销售工具包管理 · 执行工单 1/3 · 需求受理', '工单类型:执行'),
});
/* ==================== 局面二 · 内容初稿(S3/S4,只读)==================== */
/*
* **资料名点得开**(2026-08-23 用户定):标题那一格挂 `data-file-of="content_id"`,
* 渲染时换成 `data-file="content.asset/<内容ID>"` —— 点它,工作区就跳到那一份资料的
* 查看面(基座按台账那一行现造成 docx / xlsx / md / html 再读回来渲)。
* 这份件仍然只做取值:「点了去哪」不在 HTML 里,它只说清「这一格是哪一份资料」。
*
* 格式一列摆在标题右边 —— 人点之前就知道点开的是张表还是一份文档。
*/
const DRAFT_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:200px">内容标题</th>
<th style="width:70px;min-width:70px">资料</th>
<th style="min-width:130px">内容类型</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="width:110px;min-width:110px">品牌调性</th>
<th style="width:90px;min-width:90px">状态</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td data-h="资料"><span class="jc-fmt" data-class-of="file_format" data-slot="file_format" data-blank></span></td>
<td class="dimempty" data-h="内容类型"><span class="jc-vcell" data-slot="content_type" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td class="dimempty" data-h="品牌调性"><span class="jc-vcell mono" data-slot="brand_tone" data-blank></span></td>
<td data-h="状态"><span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const DRAFT_REVIEW = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 2/3',
title: '内容初稿审核',
titleRef: 'round_ref',
step: '步骤 ②③④ 产出',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_rows"></b> 份初稿</span>`,
body:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-01 宏观市场信号(产业趋势、市场依据),按 BT 层级草拟内容初稿——
BT-01 侧重供货能力 / 资质文件、BT-02 侧重技术规格书 / 替代对照表 / 认证报告、
BT-03 侧重系统方案白皮书 / 参考设计 / 成功案例;维护多语言版本(中 / 英 / 俄)。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份内容初稿是否合格,请逐项核对', [
'<b>品牌调性是否一致</b>:是否符合品牌"气质"标准(专业 / 可信赖 / 高效 / 本土化);',
'<b>技术内容是否准确</b>:规格、数据、认证信息有无错误;',
'<b>合规与多语言校对</b>:合规检查、多语言校对是否通过。',
]) +
part('AI 产出的内容初稿', '只读展示') +
DRAFT_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。通过后进入发布,驳回由 AI 重做。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 2/3 · 内容初稿审核', '工单类型:验收'),
});
/* ==================== 局面三 · 拟发布内容(S5/T_release,只读)==================== */
const PUBLISH_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:110px">内容 ID</th>
<th style="min-width:200px">标题</th>
<th style="width:120px;min-width:120px">语言版本</th>
<th style="width:80px;min-width:80px">版本号</th>
<th style="min-width:100px">适用场景</th>
<th style="width:80px;min-width:80px">BT 标签</th>
<th style="min-width:150px">状态流转</th>
</tr></thead>
<tbody data-rows="mine:content.asset">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="内容 ID"><span class="jc-vcell mono" data-slot="content_id" data-blank></span></td>
<td class="dimempty" data-h="标题"><span class="jc-vcell cust jc-fileln" data-file-of="content_id" data-slot="title" data-blank></span></td>
<td class="dimempty" data-h="语言版本"><span class="jc-vcell mono" data-slot="language" data-blank></span></td>
<td class="dimempty" data-h="版本号"><span class="jc-vcell mono" data-slot="version" data-blank></span></td>
<td class="dimempty" data-h="适用场景"><span class="jc-vcell" data-slot="scene" data-blank></span></td>
<td class="dimempty" data-h="BT 标签"><span class="jc-vcell mono" data-slot="bt_tag" data-blank></span></td>
<td data-h="状态流转"><span class="jc-vcell dim">已审核</span> → <span class="jc-tag" data-class-of="content_status"></span></td>
</tr>
</tbody>
</table></div>`;
export const PUBLISH_CONFIRM = doc({
tone: 'ledger',
badge: '验收',
tag: '验收工单',
kicker: 'I01 品牌形象与销售工具包管理 · 验收工单 3/3',
title: '发布确认',
titleRef: 'round_ref',
step: '步骤 ⑤ 发布入库',
state: `<span class="jc-rp-wait">共 <b data-slot="content_asset_content_status_published"></b> 份拟发布</span>`,
body:
part('AI 结论说明', 'AI 做了什么') +
`<div class="jc-derive">
<p><b>AI 已完成</b>:内容初稿通过审核后,拟正式发布 —— 更新版本号、状态流转(待审核 → 已审核 → 已发布)、
登记适用场景与品牌调性标签,写入 L-05 品牌内容库。本轮产出:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次发布是否无误,请逐项核对', [
'<b>版本号与状态是否正确</b>:版本号已更新、状态流转(草稿 → 已审核 → 已发布)无误;',
'<b>场景与 BT 标签是否登记完整</b>;',
'<b>多语言版本是否齐全</b>。',
]) +
part('拟发布的内容', '只读展示') +
PUBLISH_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。通过即落账写入 L-05,本次内容生产结束。'),
foot: sign('I01 品牌形象与销售工具包管理 · 验收工单 3/3 · 发布确认', '工单类型:验收 → 完结'),
});
@@ -0,0 +1,538 @@
/**
* E04 关系覆盖与关键人经营的包内自测 —— **只准 import @sdk / @contracts / @sfeel**。
* 案例活动没有 `run`,自测不跑回合;测的是清单本身与四处对齐
* (判据谓词 / 台账声明的三级占位 / 决策卡循环的骨架 / 报告模版的读写面)。
*/
import { describe, expect, it } from 'vitest';
import { activityManifestSchema } from '@contracts';
import { evaluateCondition } from '@sfeel';
import { createMockHost } from '@sdk';
import { manifest } from '../src/manifest.ts';
import activity from '../src/index.ts';
describe('清单本身合规', () => {
it('过 @contracts 的 schema', () => {
expect(() => activityManifestSchema.parse(manifest)).not.toThrow();
});
it('没有悬空监听(E04 本轮没有静态监听声明)', () => {
expect(manifest.declarations.listeners).toEqual([]);
});
it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef, c.key).toBeDefined();
}
});
it('禁区短而硬,授权边界给了调人预算', () => {
expect(manifest.contract.prohibitions.length).toBeGreaterThan(0);
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0);
});
it('每件必交付物都有台账地址', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.ledger, d.key).toBeDefined();
}
});
/*
* **人环预算必须严格大于「最后一次拍板」时的实际调人次数**(2026-08-20 实测栽过):
* 基座按 `used >= cap` 判耗尽,若 cap 恰等于完成放行所需的调人数,「裁决:pass」
* 那一次回复本身就把 used 顶到 cap,harnessCheck 在 land() 之前就判 exhausted ——
* 验收卡显示全部达标、人也点了通过,任务却终态 exhausted,草稿从没合并进正式版。
* 基线四次(S2/S4通过/S5/T_release)+ 两轮决策卡循环(每轮 +2)= 8,留够余量。
*/
it('人环预算留够余量:基线四次 + 两轮决策卡循环仍在 cap 之内', () => {
const baseline = 4;
const twoRejectRounds = baseline + 4;
expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(twoRejectRounds);
expect(manifest.contract.resources.budget.humanCalls).toBeGreaterThan(twoRejectRounds);
});
it('起单只必填 trigger,eventType/customers/note 都可选 —— 两级下拉+客户数组的形状', () => {
expect(manifest.contract.input.data['required']).toEqual(['trigger']);
const properties = manifest.contract.input.data['properties'] as Record<
string,
{ enum?: unknown[]; showWhen?: { field?: string; equals?: string } }
>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(properties[key], `入参少了 ${key}`).toBeDefined();
}
expect(properties['trigger']?.enum).toEqual(['monthly', 'event']);
expect(properties['eventType']?.enum).toEqual(['new_customer', 'key_person_change', 'major_deal', 't04_project']);
// eventType 只在 trigger:'event' 时该露面 —— 测试台靠这个字段做级联下拉
expect(properties['eventType']?.showWhen).toEqual({ field: 'trigger', equals: 'event' });
});
it('五条常备样例形状真不同:月度例行 1 条 + 事件驱动四种事件类型各 1 条', () => {
expect(manifest.samples.length).toBe(5);
const [majorDeal, monthly, keyPerson, newCustomer, t04] = manifest.samples;
type Input = { trigger?: string; eventType?: string; customers?: unknown[]; note?: string };
expect((monthly?.input as Input).trigger).toBe('monthly');
expect((monthly?.input as Input).customers).toBeUndefined();
expect((majorDeal?.input as Input).eventType).toBe('major_deal');
expect(typeof (majorDeal?.input as Input).customers?.[0]).toBe('string');
expect((keyPerson?.input as Input).eventType).toBe('key_person_change');
expect((keyPerson?.input as Input).customers).not.toEqual((majorDeal?.input as Input).customers);
// 新客户样例:customers 里是对象(新建 L-02 档案的字段),不是客户编码字符串
expect((newCustomer?.input as Input).eventType).toBe('new_customer');
const newCustomerItem = (newCustomer?.input as Input).customers?.[0] as { name?: string } | undefined;
expect(typeof newCustomerItem).toBe('object');
expect(newCustomerItem?.name).toBeDefined();
// 攻坚项目(T04)立项:四种事件类型里最后一种,点名已有客户(字符串形状)
expect((t04?.input as Input).eventType).toBe('t04_project');
expect((t04?.input as Input).customers).toEqual(['CU-0142']);
// 四种事件类型两两不同,凑齐 enum 的全部取值
const eventTypes = new Set([majorDeal, keyPerson, newCustomer, t04].map((s) => (s?.input as Input).eventType));
expect(eventTypes).toEqual(new Set(['major_deal', 'key_person_change', 'new_customer', 't04_project']));
for (const s of manifest.samples) expect(s.expect.ports).toEqual(['coverage-map']);
});
/*
* **落到这个活动上就该看见一份填好的入参**(2026-08-23 用户:「询价受理起单时左边
* 都有预填好的活动输入,E04 也要」)。测试台自动铺的是 `samples[0]`,所以第一条
* 必须四个字段全有值 —— 从前第一条是月度例行、只有 trigger 一个键,表单看着像没预填。
* 每一条也都至少要有背景说明:整屏只有一个下拉有值的样例,人看不出这活动吃什么。
*/
it('样例 0 是满载的那条(四个字段全有值)—— 测试台落地即预填', () => {
const first = manifest.samples[0]?.input as Record<string, unknown>;
for (const key of ['trigger', 'eventType', 'customers', 'note']) {
expect(first[key], `样例 0 缺 ${key}`).toBeDefined();
}
expect((first['customers'] as unknown[]).length).toBeGreaterThan(0);
});
it('每条样例都带本轮说明,且样例名 ≤14 字(超了测试台只显示「样例 N」)', () => {
for (const s of manifest.samples) {
expect((s.input as { note?: string }).note, `样例「${s.name}」没有 note`).toBeTruthy();
expect(s.name.length, `样例名「${s.name}」超过 14 字`).toBeLessThanOrEqual(14);
}
});
it('主数据追加列全部显式标 kind:master —— 与平台声明一致,不然静态审会拒', () => {
for (const object of ['profile', 'contact']) {
const decl = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === object);
expect(decl?.kind, `customer.${object}`).toBe('master');
}
});
it('influence / priority / completion_status 三个 tally 列的取值域全是 ASCII', () => {
for (const d of manifest.declarations.ledger) {
for (const f of d.fields) {
for (const value of f.tally ?? []) {
expect(/^[a-z0-9_]+$/.test(value), `${d.domain}.${d.object}.${f.name} 的取值 ${value} 不是纯 ASCII —— data-class-of 会把它清空成空 class`).toBe(true);
}
}
}
});
it('customer.profile 新增 cust_type:L-02 文档明列的字段,expect 而非 required,不带 tally', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const custType = profile?.fields.find((f) => f.name === 'cust_type');
expect(custType, 'customer.profile 缺 cust_type').toBeDefined();
expect(custType?.expect).toBe(true);
expect(custType?.required).toBeUndefined();
expect(custType?.tally).toBeUndefined();
});
it('crm_coverage.verdict 新增 event_type 列(人读标签,月度例行留空)', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.find((f) => f.name === 'event_type')).toBeDefined();
});
it('我方追加的批注列(change_log)不标 expect —— 标了会让 _gaps 永远大于零', () => {
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
expect(contact?.fields.find((f) => f.name === 'change_log')?.expect).toBeUndefined();
});
it('候选客户清单的客户编码/客户名称都标 required —— 业务说明明文两列必填', () => {
const candidate = manifest.declarations.ledger.find((d) => d.object === 'candidate');
expect(candidate?.fields.find((f) => f.name === 'customer_code')?.required).toBe(true);
expect(candidate?.fields.find((f) => f.name === 'customer_name')?.required).toBe(true);
});
it('receipt 的完成状态/结果摘要/关键人变动三列必填,客户反馈选填', () => {
const receipt = manifest.declarations.ledger.find((d) => d.object === 'receipt');
for (const col of ['completion_status', 'result_summary', 'key_person_change']) {
expect(receipt?.fields.find((f) => f.name === col)?.required, col).toBe(true);
}
expect(receipt?.fields.find((f) => f.name === 'customer_feedback')?.required).toBeUndefined();
});
it('两个工具都是纯工具(pure),批量入参 customers/contacts 优先于单条', () => {
const declared = new Set(manifest.declarations.tools.map((t) => t.name));
expect(declared.has('crm-relationship-coverage.recompute-score')).toBe(true);
expect(declared.has('crm-relationship-coverage.rank-key-persons')).toBe(true);
});
});
describe('案例模型 —— 双表示同版本', () => {
const model = manifest.assets.businessModel;
const cmmn = parseCmmnLite(model.cmmnXml);
it('CMMN 与说明书同在,不允许分叉', () => {
expect(model.cmmnXml).toContain('<casePlanModel');
expect(model.narrative.length).toBeGreaterThan(400);
});
/*
* 收口拆两项(2.0.4 结构治):写回主数据(S6_writeback)与留更新明细(S6_ledger_update)
* 各一项 —— 从前一项三拍,模型照样把四张表挤进一串,串尾最费字数的明细表被截成
* 空数组挨拒(两轮实测同一处);剧本按项摊开,拆开之后串想长也长不起来。
*/
it('八个计划项:识别候选 / 人核对 / 制定计划 / 审批 / 执行回执 / 写回主数据 / 留更新明细 / 放行', () => {
expect(items(model.cmmnXml)).toEqual([
'S1_identify_candidates',
'S2_scope_confirm',
'S3_plan',
'S4_plan_approval',
'S5_execute_receipt',
'S6_writeback',
'S6_ledger_update',
'T_release',
]);
});
it('四处人的门都标了人工激活:核对范围 / 审批 / 执行回执 / 放行(识别/制定计划/写台账是查表或AI起草,不标)', () => {
expect(cmmn.manualActivation).toEqual(['S2_scope_confirm', 'S4_plan_approval', 'S5_execute_receipt', 'T_release']);
});
it('不用阶段 / 里程碑 / 裁量项', () => {
for (const tag of ['<stage', '<milestone', '<planningTable', '<discretionaryItem']) {
expect(model.cmmnXml, `不该出现 ${tag}`).not.toContain(tag);
}
});
it('人工激活的四项在剧本里都没给 doneWhen —— 给了就是给引擎替人签字的钥匙', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) expect(step.doneWhen, `${step.item} 不该有 doneWhen`).toBeUndefined();
}
});
it('AI 起草的三项都给了 doneWhen,且不是「未绑定名字恒真」那种坑', () => {
const manual = new Set(cmmn.manualActivation);
for (const step of model.steps ?? []) {
if (manual.has(step.item)) continue;
expect(step.doneWhen, `${step.item} 缺 doneWhen`).toBeDefined();
expect(step.doneWhen, `${step.item} 的 doneWhen 不许用 != null`).not.toContain('!= null');
}
});
/*
* **放行门的入场哨兵认「办完了没有」,四处全读基座数**(2026-08-02 那类「只有 onPart
* 没有 ifPart 等于没有门」的坑,与「写台账要给行 rows 为空」的哨兵松绑同一类)。
*/
it('放行门哨兵带 ifPart,四个条件全是基座读数或本步显式记账的旗', () => {
const condition = sentryIf(model.cmmnXml, 's_ledger_updated');
expect(condition).toContain('ledger_update_confirmed_total');
expect(condition).toContain('crm_coverage_ledger_update_rows');
expect(condition).toContain('crm_coverage_ledger_update_missing');
expect(condition).toContain('customer_profile_missing');
expect(condition).toContain('customer_contact_missing');
const done = {
ledger_update_confirmed_total: 1,
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
};
expect(safeEval(condition, done)).toBe(true);
// 写完了但主数据还缺必填 → 门不开
expect(safeEval(condition, { ...done, customer_profile_missing: 1 })).toBe(false);
// 写过一笔但还没显式记账(旗还没置一)→ 门不开(踩过「只有 onPart 没有 ifPart」的坑)
expect(safeEval(condition, { ...done, ledger_update_confirmed_total: 0 })).toBe(false);
});
it('S3 制定计划的入场哨兵认「人核对过了」,不认「AI 起草过」', () => {
const condition = sentryIf(model.cmmnXml, 's_scope_confirmed');
expect(condition).toContain('scope_confirmed_total');
expect(safeEval(condition, { scope_confirmed_total: 1 })).toBe(true);
expect(safeEval(condition, {})).toBe(false);
});
it('每个计划项都有剧本,guide 有实质内容', () => {
const ids = items(model.cmmnXml);
expect((model.steps ?? []).map((s) => s.item)).toEqual(ids);
for (const step of model.steps ?? []) {
expect(step.guide.length, `${step.item} 的做法`).toBeGreaterThan(80);
expect(step.intent.length, `${step.item} 的意图`).toBeGreaterThan(6);
}
});
it('说明书不点名不存在的计划项', () => {
const named = [...model.narrative.matchAll(/\b(?:S\d+_[A-Za-z_]+|T_[a-z]+)\b/g)].map((m) => m[0]);
const ids = new Set(items(model.cmmnXml));
for (const name of named) {
expect(ids.has(name), `说明书提到的 ${name} 不在案例模型里`).toBe(true);
}
});
/*
* **决策卡循环三处写死的东西也进说明书**(照 A6 T_haggle 立的规矩):
* itemId / 三个 value / allowCustom 断点那几拍模型手上只剩这份说明书。
*/
it('说明书写死了决策卡的 itemId / 三个 value / allowCustom', () => {
expect(model.narrative).toContain('S4_plan_approval');
for (const value of ['approve', 'reject', 'custom:']) {
expect(model.narrative, `说明书少了 value ${value}`).toContain(value);
}
expect(model.narrative).toContain('allowCustom');
});
/*
* **重发判断走在「问理由」之前**(2026-08-20 实测逼出来的修复):固定按钮点出来的
* 驳回永远没有 note,再问一遍是把人指向一扇打不开的门。S4 的 guide 必须写死
* 「不许再发卡追问理由」,而不是 A6 T_haggle 那种「找不到答复才发卡」的单发模式
* (E04 的决策卡允许多轮循环,与 A6 恰好相反,两处不能弄混)。
*/
it('S4 的 guide 写死「reject 不许追问理由」,不是无脑重问', () => {
const s4 = (model.steps ?? []).find((s) => s.item === 'S4_plan_approval');
expect(s4?.guide).toContain('不许再发卡追问理由');
});
/*
* **新客户建档的数据录入卡骨架也写死进说明书**(与 S4 决策卡同一条纪律):
* "断点"那几拍模型手上只保证摊开 NARRATIVE,S1 自己的 guide 不一定摊开。
* 照抄 A1 quote-intake 的 S1_customer 机制——`customer_code` 从不给 AI 编,
* 必须由人在卡上现场给。
*/
it('说明书写死了新客户录入卡的骨架:entry / customer.profile / customer_code 现场给', () => {
expect(model.narrative).toContain('"kind":"entry"');
expect(model.narrative).toContain('"ledger":"customer.profile"');
expect(model.narrative).toContain('customer_code');
expect(model.narrative, '不许自己编客户编码').toContain('不能自己编一个');
});
it('S1 的 guide 写死「人确认后不许再 write 一遍 customer.profile/.contact」', () => {
const s1 = (model.steps ?? []).find((s) => s.item === 'S1_identify_candidates');
expect(s1?.guide).toContain('不许你再');
expect(s1?.guide).toContain('customer.profile');
expect(s1?.guide).toContain('customer.contact');
});
});
describe('声明与契约对得上', () => {
const columns = new Set(
manifest.declarations.ledger.flatMap((d) => [`${d.domain}.${d.object}`, ...d.fields.map((f) => f.name)]),
);
it('必交付物的台账地址都声明过', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(columns.has(d.ledger ?? ''), `${d.key} → ${d.ledger}`).toBe(true);
}
});
it('四件必交付物 editableAtRelease 全是空数组 —— 验收工单只读展示,不可修改', () => {
for (const d of manifest.contract.requiredDeliverables) {
expect(d.editableAtRelease, d.key).toEqual([]);
}
});
const rowReadings = new Set([
...manifest.declarations.ledger.map((d) => `${d.domain}_${d.object}_rows`),
...manifest.declarations.ledger
.filter((d) => d.fields.some((f) => f.required === true || f.requiredWhen !== undefined))
.map((d) => `${d.domain}_${d.object}_missing`),
...manifest.declarations.ledger.flatMap((d) =>
d.fields.flatMap((f) =>
(f.tally ?? []).map((v) => `${d.domain}_${d.object}_${f.name}_${v.toLowerCase().replace(/[^a-z0-9_]/g, '')}`),
),
),
]);
it('L1/L2 谓词读的名字都有承载(台账列或基座读数)', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
for (const identifier of (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? []) {
if (['and', 'or', 'not', 'true', 'false', 'null', 'in', 'between'].includes(identifier)) continue;
if (rowReadings.has(identifier)) continue;
if (identifier === 'plan_approved_total' || identifier === 'scope_confirmed_total') continue; // 一次性记账旗,直接是 caseFileItem
expect(columns.has(identifier), `${c.key} 的谓词引用了 ${identifier}`).toBe(true);
}
}
});
it('每条 L1/L2 都至少读一个基座读数或本步记账旗 —— 不许两边都由活动自报凑数', () => {
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L1' && c.level !== 'L2') continue;
const names = (c.predicate ?? '').match(/[a-z_][a-z0-9_]*/g) ?? [];
const grounded = names.some((n) => rowReadings.has(n) || n.endsWith('_total'));
expect(grounded, `${c.key} 一个基座读数/记账旗都没读`).toBe(true);
}
});
it('单一必发口 coverage-map,无选择组(E04 全程线性,无分支)', () => {
expect(manifest.contract.output.mandatory.map((p) => p.name)).toEqual(['coverage-map']);
expect(manifest.contract.output.selective).toEqual([]);
});
it('coverage-map 出口标量字段都有台账列承载', () => {
const snake = (name: string) => name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`);
const port = manifest.contract.output.mandatory[0];
const properties = (port?.payload as { properties?: Record<string, { type?: string }> }).properties ?? {};
for (const [field, spec] of Object.entries(properties)) {
if (spec.type === 'array') continue;
expect(columns.has(snake(field)), `${port?.name}.${field} → ${snake(field)}`).toBe(true);
}
});
it('L4 判据裁量语句整句出现在放行剧本里 —— judge 才对得上名', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
for (const c of manifest.contract.acceptanceCriteria) {
if (c.level !== 'L4') continue;
expect(release?.guide, `T_release 的 guide 要整句抄 ${c.key}`).toContain(c.label);
}
});
});
describe('报告模版 —— 活动持有,容器无脚本执行权', () => {
it('四个局面各一张:候选清单 / 经营计划 / 执行回执 / 台账更新明细', () => {
expect(manifest.assets.viewTemplates.map((t) => t.name)).toEqual([
'coverage-scope',
'coverage-plan',
'coverage-receipt',
'coverage-ledger-update',
]);
});
it('剧本每一步的 view 都在模版名单里', () => {
const names = new Set(manifest.assets.viewTemplates.map((t) => t.name));
for (const step of manifest.assets.businessModel.steps ?? []) {
expect(names.has(step.view ?? ''), `${step.item} 的 view=${step.view}`).toBe(true);
}
});
it('放行那一步摆的是台账更新明细 —— 最后摆上去的那张就是归档件', () => {
const release = manifest.assets.businessModel.steps?.find((s) => s.item === 'T_release');
expect(release?.view).toBe('coverage-ledger-update');
});
it('模版里没有脚本、没有事件处理器、没有外链 —— 样式自带', () => {
for (const template of manifest.assets.viewTemplates) {
expect(template.html, template.name).not.toMatch(/<script|\son[a-z]+=|https?:\/\//i);
expect(template.html, template.name).toContain('<style>');
}
});
it('候选清单可编辑:data-edit 指向 crm_coverage.candidate,主键客户编码挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.candidate"');
expect(html).toContain('data-col="customer_code"');
});
it('经营计划只读:没有任何 data-edit / data-col —— 人只审不改', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(html).not.toContain('data-edit=');
expect(html).not.toContain('data-col=');
});
it('台账更新明细只读:没有任何 data-edit / data-col —— 归档件不给改', () => {
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=');
});
it('执行回执混排:主键联系人编码挂 data-col,四个可填列各自挂了 data-col', () => {
const html = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(html).toContain('data-edit="crm_coverage.receipt"');
const cols = new Set([...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1]));
expect(cols).toEqual(
new Set(['contact_code', 'completion_status', 'result_summary', 'customer_feedback', 'key_person_change']),
);
});
it('每个可编辑格子的 data-col 都在对应台账对象的声明里 —— 编辑面不能超出声明的字段', () => {
const editableViews: Array<[string, string]> = [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
];
for (const [view, object] of editableViews) {
const html = manifest.assets.viewTemplates.find((t) => t.name === view)?.html ?? '';
const declared = new Set(manifest.declarations.ledger.find((d) => d.object === object)?.fields.map((f) => f.name));
for (const col of [...html.matchAll(/data-col="([^"]+)"/g)].map((m) => m[1])) {
expect(declared.has(col ?? ''), `${view} 的 data-col="${col}" 不在 crm_coverage.${object} 的声明里`).toBe(true);
}
}
});
it('缺必填红带与提交闸同源 —— 可编辑件上摆的是基座的 `_missing` 与主键名单', () => {
for (const [name, object] of [
['coverage-scope', 'candidate'],
['coverage-receipt', 'receipt'],
] as const) {
const html = manifest.assets.viewTemplates.find((t) => t.name === name)?.html ?? '';
expect(html, name).toContain(`crm_coverage_${object}_missing`);
expect(html, name).toContain(`crm_coverage_${object}_missing_keys`);
}
});
it('三个状态徽(候选状态/优先级/完成状态)只用 data-class-of 投影,不写 data-slot 重复暴露原值', () => {
const scope = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-scope')?.html ?? '';
expect(scope).toContain('<span class="jc-tag" data-class-of="status"></span>');
const plan = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-plan')?.html ?? '';
expect(plan).toContain('<span class="jc-tag" data-class-of="priority"></span>');
const receipt = manifest.assets.viewTemplates.find((t) => t.name === 'coverage-receipt')?.html ?? '';
expect(receipt).toContain('<span class="jc-tag" data-class-of="completion_status"></span>');
});
it('宿主拒推含脚本的模版', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.base.interaction.pushHtml('<div><script>x()</script></div>')).rejects.toThrow(/无脚本执行权/);
});
});
const CONTRACT = {
activityId: 'crm-relationship-coverage',
version: '1.0.0',
data: {},
message: undefined,
outputPorts: { mandatory: ['coverage-map'], selective: [] },
prohibitions: [],
discretion: [],
} as const;
describe('代码入口 —— 案例活动没有 run,不是残包', () => {
it('默认导出是一个有名字的活动定义', () => {
expect(activity.name).toBe('crm-relationship-coverage');
});
it('mock 宿主如实报「没有 run」,不静默返回一个假的 outcome', async () => {
const host = createMockHost({ contract: CONTRACT });
await expect(host.run(activity)).rejects.toThrow(/没有 run/);
});
});
function items(xml: string): string[] {
return [...xml.matchAll(/<(?:humanTask|decisionTask|processTask|caseTask) id="([^"]+)"/g)].map((m) => m[1] ?? '');
}
/**
* 有的 humanTask 是自闭合标签(`<humanTask .../>`,如没有人工激活的 S1)——
* `[\s\S]*?<\/humanTask>` 这种非贪婪匹配会跳过自闭合标签、错吞下一个 humanTask
* 的内容(把 S2 的 manualActivationRule 记到 S1 头上)。两种收尾分别匹配。
*/
function parseCmmnLite(xml: string): { manualActivation: string[]; repetition: string[] } {
const manualActivation: string[] = [];
const repetition: string[] = [];
for (const m of xml.matchAll(/<humanTask id="([^"]+)"[^>]*?(?:\/>|>[\s\S]*?<\/humanTask>)/g)) {
if (m[0].includes('manualActivationRule')) manualActivation.push(m[1] ?? '');
if (m[0].includes('repetitionRule')) repetition.push(m[1] ?? '');
}
return { manualActivation, repetition };
}
function sentryIf(xml: string, sentryId: string): string {
const block = xml.slice(xml.indexOf(`<sentry id="${sentryId}"`));
const raw = /<condition>([\s\S]*?)<\/condition>/.exec(block.slice(0, block.indexOf('</sentry>')))?.[1] ?? '';
return raw.replaceAll('&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&');
}
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1,317 @@
/**
* 步骤剧本 —— **这段错了不报错,只是活动开始转圈**(与 quote-* 各包同一套惯例)。
*
* E04 的实测账本(2026-08-21/22)每一条都对应这里的一类断言:
* · S1 只写一行就 finish → doneWhen 的「该有几行」半句 + count(customers) 语义;
* · S5 抢跑写旗 → 人工激活项不给 doneWhen(activity.test.ts 钉)+ 收口列写全;
* · 收口漏 influence → 新客户路放行门永远打不开(「碰过的行三列都带上」);
* · 剧本 writes/tools 指向没声明的地址 → 运行时被「台账地址未声明」拒到熔断。
*/
import { describe, expect, it } from 'vitest';
import { evaluateCondition } from '@sfeel';
import { CMMN_XML, COVERAGE_PHASES, COVERAGE_STEPS } from '../src/case-model.ts';
import { COVERAGE_LEDGER_UPDATE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_SCOPE } from '../src/views/report.ts';
import { manifest } from '../src/manifest.ts';
/** 从 CMMN XML 里把计划项 id 抠出来 —— 剧本认的是它们 */
const ITEM_IDS = [...CMMN_XML.matchAll(/<(?:humanTask|decisionTask|stage|milestone)\s+id="([^"]+)"/g)].map(
(m) => m[1] ?? '',
);
describe('剧本与案例模型对得上 —— 对不上的条目等于没写', () => {
it('每一条剧本都指向一个真实存在的计划项', () => {
for (const step of COVERAGE_STEPS) {
expect(ITEM_IDS, `剧本里的 ${step.item} 在 CMMN 里不存在`).toContain(step.item);
}
});
it('图上每一步都有剧本 —— 漏掉的那一步会退回整本说明书', () => {
for (const id of ITEM_IDS) {
expect(COVERAGE_STEPS.map((s) => s.item), `计划项 ${id} 没有剧本`).toContain(id);
}
});
it('清单里挂的就是这份剧本与这份阶段(不是另抄一份)', () => {
expect(manifest.assets.businessModel.steps).toBe(COVERAGE_STEPS);
expect(manifest.assets.businessModel.phases).toBe(COVERAGE_PHASES);
});
it('除放行外每个计划项恰属一个阶段 —— 进度条不许漏格,也不许一项占两格', () => {
const inPhases = COVERAGE_PHASES.flatMap((p) => p.items);
const expected = ITEM_IDS.filter((id) => id !== 'T_release');
expect([...inPhases].sort()).toEqual([...expected].sort());
expect(new Set(inPhases).size).toBe(inPhases.length);
});
});
describe('剧本声明的读写面 ⊆ 清单声明 —— 越出去的地址运行时才炸', () => {
const declaredAddresses = new Set(manifest.declarations.ledger.map((d) => `${d.domain}.${d.object}`));
const declaredTools = new Set(manifest.declarations.tools.map((t) => t.name));
it('steps[].writes 全部落在声明过的台账对象上', () => {
for (const step of COVERAGE_STEPS) {
for (const address of step.writes) {
expect(declaredAddresses.has(address), `${step.item} 要写 ${address},清单没声明`).toBe(true);
}
}
});
it('steps[].tools 全部是声明过的工具', () => {
for (const step of COVERAGE_STEPS) {
for (const tool of step.tools) {
expect(declaredTools.has(tool), `${step.item} 要调 ${tool},清单没声明`).toBe(true);
}
}
});
});
describe('doneWhen 是「成果已存在」的判据 —— 开局不许真,干完必须真', () => {
/** 四条常备样例各自的开案局面:台账一行没有,基座读数按声明铺 0 */
const fresh = (input: Record<string, unknown>): Record<string, unknown> => ({
...input,
crm_coverage_candidate_rows: 0,
crm_coverage_candidate_missing: 0,
crm_coverage_plan_rows: 0,
crm_coverage_plan_missing: 0,
crm_coverage_receipt_rows: 0,
crm_coverage_receipt_missing: 0,
crm_coverage_ledger_update_rows: 0,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
});
it('**开局一步都不该被自动收掉** —— 五条样例的入参形状逐一试', () => {
for (const sample of manifest.samples) {
for (const step of COVERAGE_STEPS) {
if (step.doneWhen === undefined) continue;
const ripe = safeEval(step.doneWhen, fresh(sample.input as Record<string, unknown>));
expect(ripe, `样例「${sample.name}」下 ${step.item} 的 doneWhen 开局就为真`).toBe(false);
}
}
});
/*
* **count(customers) 的三种形状钉死**(2026-08-22 注释与实现相悖那一课):
* sfeel 的 count() 对未绑定名 flatten 出 [undefined],长度 1 —— 不是 0。
* 月度例行(键不存在)靠 rows >= 1 与 rows > 0 同义而行为恰好正确;
* 显式空数组才走 count = 0 那半句。这里用真 @sfeel 求值,注释再想撒谎测试拦着。
*/
it('月度例行(customers 键不存在):写下 1 行且必填齐 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('点名两家客户只写了一行 → S1 收不掉(漏写的那家不会自己回来)', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(false);
});
it('点名两家客户写齐两行 → S1 收得掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'event',
customers: ['CU-0142', 'CU-0087'],
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('显式传空数组(customers: []):count = 0 那半句真的兜住', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
customers: [],
crm_coverage_candidate_rows: 1,
crm_coverage_candidate_missing: 0,
}),
).toBe(true);
});
it('行齐了但缺必填 → S1 收不掉', () => {
expect(
safeEval(doneOf('S1_identify_candidates'), {
trigger: 'monthly',
crm_coverage_candidate_rows: 2,
crm_coverage_candidate_missing: 1,
}),
).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('台账更新明细齐 + 主数据必填齐 → S6 收得掉', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 0,
}),
).toBe(true);
});
it('**收口写了明细却漏了联系人必填(如 influence)→ S6 收不掉** —— 新客户路的死锁口', () => {
expect(
safeEval(doneOf('S6_ledger_update'), {
crm_coverage_ledger_update_rows: 4,
crm_coverage_ledger_update_missing: 0,
customer_profile_missing: 0,
customer_contact_missing: 1,
}),
).toBe(false);
});
});
describe('实测账本对应的剧本口径 —— 改口要连测试一起改', () => {
const writeback = COVERAGE_STEPS.find((s) => s.item === 'S6_writeback');
const ledgerUpdate = COVERAGE_STEPS.find((s) => s.item === 'S6_ledger_update');
it('写回主数据那一项含 candidate(状态置 covered)与联系人三列的硬要求', () => {
expect(writeback?.writes).toContain('crm_coverage.candidate');
expect(writeback?.guide).toContain('covered');
for (const column of ['influence', 'relationship', 'key_person']) {
expect(writeback?.guide, `S6_writeback guide 没点名 ${column}`).toContain(column);
}
expect(writeback?.guide).toContain('新建档的联系人');
});
/*
* **放行那一步要接得住「被打回」**(2026-08-23,柴浩实测:点重做写了意见,模型照
* T_release 的剧本「裁判据 → submit」原样又递回来 —— 剧本里压根没有「被打回了怎么办」
* 这一支)。基座那道闸只拦得住原样重提,拦不出「该改哪一张表」——那是活动自己的知识。
*/
it('放行剧本带「被打回重做」那一支,且写面覆盖要改的那几张表', () => {
const release = COVERAGE_STEPS.find((s) => s.item === 'T_release');
expect(release?.guide).toContain('验收被打回重做');
for (const address of ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict']) {
expect(release?.writes, `T_release 改不了 ${address}`).toContain(address);
}
// 改的是值不是数 —— 新开行会把收步判据弄假
expect(release?.guide).toContain('覆盖写');
});
/*
* 收口拆两项的口径钉死(2.0.4 结构治):留明细那一项**只**写明细与结论 ——
* 写面一旦混进别的表,「一串挤四张、串尾截成空数组」那口锅迟早再背一次。
*/
it('留更新明细那一项只写 ledger_update + verdict 两张表', () => {
expect(ledgerUpdate?.writes).toEqual(['crm_coverage.ledger_update', 'crm_coverage.verdict']);
expect(ledgerUpdate?.tools).toEqual([]);
});
it('verdict 的触发列叫 trigger_label —— 与入参 trigger 同名会让首帧摆英文码', () => {
const verdict = manifest.declarations.ledger.find((d) => d.object === 'verdict');
expect(verdict?.fields.some((f) => f.name === 'trigger_label')).toBe(true);
expect(verdict?.fields.some((f) => f.name === 'trigger')).toBe(false);
});
it('plan 没有 status 列 —— 「过没过审」由 plan_approved_total 一处说了算', () => {
const plan = manifest.declarations.ledger.find((d) => d.domain === 'crm_coverage' && d.object === 'plan');
expect(plan?.fields.some((f) => f.name === 'status')).toBe(false);
});
it('建档卡要写的六列都在声明里 —— 授权面是台账声明,说明书不许开空头支票', () => {
const profile = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'profile');
const contact = manifest.declarations.ledger.find((d) => d.domain === 'customer' && d.object === 'contact');
for (const column of ['name', 'industry', 'region']) {
expect(profile?.fields.some((f) => f.name === column), `customer.profile 缺 ${column}`).toBe(true);
}
for (const column of ['name', 'title', 'email']) {
expect(contact?.fields.some((f) => f.name === column), `customer.contact 缺 ${column}`).toBe(true);
}
// 邮箱照平台口径标脱敏 —— 不标的话治理面那一层裁剪不到它
expect(contact?.fields.find((f) => f.name === 'email')?.masked).toBe(true);
});
});
describe('四份工单模版照目标件 §6.2 补齐的动态位', () => {
const ALL = [COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE];
it('卡头:收件人 + 关联任务;side:步骤行;页脚:单号 —— 四份都有', () => {
for (const view of ALL) {
expect(view).toContain('data-slot="case:owner"');
expect(view).toContain('data-slot="case:taskId"');
expect(view).toContain('jc-rp-step');
// 页脚右侧「工单类型:… · <单号>」—— round_ref 是唯一真数据源
expect(view).toContain('data-slot="round_ref"');
}
});
/*
* **枚举列上下两态说同一个字**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」):存的是 ASCII(台账与 recompute-score 的 btLevel 入参认它),
* 那么**只读那一份必须走 data-class-of 徽标**,由样式表出人读名 ——
* data-slot 直显会把 bt-02 摆到人脸上,与编辑态下拉里的「BT-02 · 技术」对不上。
* 覆盖深度当初修过这个坑,BT 层级漏了;列宽也得放得下选项文字,否则下拉被挤扁。
*/
it('BT 层级与覆盖深度:只读态走徽标(不 data-slot 直显 ASCII),且人读名在样式表里', () => {
for (const column of ['bt_level', 'depth']) {
expect(COVERAGE_SCOPE).toContain(`data-class-of="${column}"`);
expect(COVERAGE_SCOPE, `${column} 不该 data-slot 直显机器码`).not.toContain(`data-slot="${column}"`);
}
for (const label of ['BT-01 · 采购/供应链', 'BT-02 · 技术', 'BT-03 · 高层']) {
expect(COVERAGE_SCOPE, `徽标字典缺「${label}」`).toContain(label);
}
});
it('BT 层级列放得下选项文字 —— 80px 那会儿下拉被挤扁,选了什么看不见', () => {
const head = /<th[^>]*>BT 层级<\/th>/.exec(COVERAGE_SCOPE)?.[0] ?? '';
const width = Number(/min-width:(\d+)px/.exec(head)?.[1] ?? 0);
expect(width, `BT 层级列宽 ${width}px,装不下「BT-01 · 采购/供应链」`).toBeGreaterThanOrEqual(140);
});
it('执行工单1 的编辑闸照目标件措辞:「AI 已识别 N 个客户」,N 是基座读数', () => {
expect(COVERAGE_SCOPE).toContain('AI 已识别');
expect(COVERAGE_SCOPE).toContain('data-slot="crm_coverage_candidate_rows"');
// 触发口径并进【做什么】,不再单开一段(照目标件的 note 结构)
expect(COVERAGE_SCOPE).not.toContain('【本轮触发】');
expect(COVERAGE_SCOPE).toContain('data-slot="trigger_label"');
});
it('经营计划表摆得出时间点 —— 验收判据③指向的东西必须在卡面上', () => {
expect(COVERAGE_PLAN).toContain('data-slot="target_date"');
});
it('执行回执:对象列长「客户 · 关键人」;结果摘要/客户反馈照目标件是 textarea', () => {
expect(COVERAGE_RECEIPT).toContain('data-slot="customer_name"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="result_summary"');
expect(COVERAGE_RECEIPT).toContain('<textarea class="jc-cell" data-col="customer_feedback"');
});
it('台账更新确认摆得出依据与编码 —— 三条 L4 判据不再让人判他看不见的东西', () => {
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="evidence_ref"');
expect(COVERAGE_LEDGER_UPDATE).toContain('data-slot="customer_code"');
});
});
function doneOf(item: string): string {
const found = COVERAGE_STEPS.find((s) => s.item === item)?.doneWhen;
if (found === undefined) throw new Error(`${item} 没有 doneWhen`);
return found;
}
/** 谓词写坏了引擎当作「没算出来」——测里也照这个口径,断言才对得上运行时 */
function safeEval(expression: string, scope: Record<string, unknown>): boolean {
try {
return evaluateCondition(expression, scope);
} catch {
return false;
}
}
@@ -0,0 +1 @@
{ "name": "@activities/crm-relationship-coverage", "version": "0.1.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" } }
@@ -0,0 +1,717 @@
/**
* E04 关系覆盖与关键人经营的案例模型 —— **双表示同版本**:CMMN XML(机器面)与
* 案例说明书(叙事面)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* 收成 2 张执行工单 + 2 张验收工单)与 `docs/SS01-客户与市场/Demo-E04-I01-Activity台账
* 关联与设计.html`(L-02/L-08 台账读写关系)。
*
* 7 个业务步收成 8 个案例模型项,非逐一对应:
* ① 启动与覆盖范围确认 → S1_identify_candidates(AI 起草)+ S2_scope_confirm(人核对);
* ②③④ 读现状→缺口分析→制定计划 → S3_plan(一项,AI)+ S4_plan_approval(决策卡循环);
* ⑤⑥ 执行经营动作→更新关系台账 → S5_execute_receipt(人执行填回执)+
* S6_writeback(AI 写回主数据)+ S6_ledger_update(AI 留更新明细与结论 ——
* 与写回拆成两项是 2.0.4 的结构治:一串挤四张表,串尾最费字数的明细表
* 被截成空数组挨拒,实测两轮都栽在同一处;拆开后每项的剧本只摊自己那几张表);
* ⑦ 复盘与完结 → T_release(终局验收放行)。
*
* 这是本仓库第一个用 `decide` 实现「非终局通过/驳回+AI重做」循环的活动
* (S4_plan_approval)——`issueAcceptance` 硬绑定终局放行,一个任务只能发一次,
* 不能拿来做②③④这个中间验收点;`decide` 才是唯一能循环的机制,细则见 S4 的 guide。
*/
export const CMMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL">
<case id="crm-coverage">
<!--
案卷项的 id 就是任务视图里的键名。入参顶层是 trigger/eventType/customers/note 四个
字段(不进这份 caseFileModel——没有谓词引用它们,S1 的步骤 reads 里单独声明);
crm_coverage_* 与 customer_profile_* / customer_contact_* 那几个是基座读数
(行数 / 缺必填,由基座现算,模型碰不着)。
-->
<caseFileModel>
<caseFileItem id="crm_coverage_candidate_rows" name="候选客户行数(基座现数)"/>
<caseFileItem id="crm_coverage_candidate_missing" name="候选清单缺必填的行数(基座现数)"/>
<caseFileItem id="scope_confirmed_total" name="覆盖范围已核对确认(人环走完的凭据)"/>
<caseFileItem id="crm_coverage_plan_rows" name="经营计划行数(基座现数)"/>
<caseFileItem id="crm_coverage_plan_missing" 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="customer_profile_rows" name="本单写下的客户档案行数(基座现数)"/>
<caseFileItem id="customer_profile_missing" name="客户档案缺必填的行数(基座现数)"/>
<caseFileItem id="customer_contact_rows" name="本单写下的客户联系人行数(基座现数)"/>
<caseFileItem id="customer_contact_missing" name="客户联系人缺必填的行数(基座现数)"/>
<caseFileItem id="ledger_update_confirmed_total" name="台账更新已收口(S6 写完时置一)"/>
</caseFileModel>
<casePlanModel id="plan" name="关系覆盖与关键人经营" autoComplete="true">
<!-- ① 候选客户已起草 —— 人核对那一步的入场前提 -->
<sentry id="s_candidates_drafted">
<onPart id="op_cd" caseFileItemRef="crm_coverage_candidate_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_cd"><condition>crm_coverage_candidate_rows &gt; 0</condition></ifPart>
</sentry>
<!-- ② 覆盖范围已核对 —— 制定经营计划那一步的入场前提 -->
<sentry id="s_scope_confirmed">
<onPart id="op_sc" caseFileItemRef="scope_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_sc"><condition>scope_confirmed_total &gt; 0</condition></ifPart>
</sentry>
<!-- ③ 经营计划已起草且齐 —— 审批那一步的入场前提 -->
<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>
</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>
</sentry>
<!--
⑤ 执行回执已交回且齐 —— 收口写台账那一步的入场前提。
**不再有 receipt_confirmed_total 记账旗**(2.0.2 退役,活单熔断两轮实测逼的):
那面旗是模型写的,S4 通过后它顺手把旗写上就坚信 S5 已办完,连续五拍
[空转写旗 + finish 被 worldTouched 拒] 熔断 —— 「该不该往下走」不能由
被叫的那一方自己记账。回执四列全是**人在件上填出来的**,_rows/_missing
是基座数出来的 —— 人填齐那一刻门自己开,模型在 S5 只剩发卡与收步两件事,
没有可抢跑的旗。
-->
<sentry id="s_receipt_confirmed">
<onPart id="op_rc" caseFileItemRef="crm_coverage_receipt_missing"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_rc"><condition>crm_coverage_receipt_rows &gt; 0 and crm_coverage_receipt_missing = 0</condition></ifPart>
</sentry>
<!--
⑥(中缝)主数据已写回 —— 留更新明细那一步的入场前提。
收口从一项拆成两项(2.0.4,治「一串挤四张表、串尾被截成空数组」的实测账):
写回主数据(S6_writeback)与留明细(S6_ledger_update)各一项,剧本各只摊自己
要写的表,一串自然变小。四个数全是基座现数:重算分/档案都写齐了,门才开。
-->
<sentry id="s_masters_written">
<onPart id="op_mw" caseFileItemRef="customer_profile_rows"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_mw"><condition>customer_profile_rows &gt; 0 and customer_profile_missing = 0 and customer_contact_rows &gt; 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!--
⑥ 台账已收口 —— 放行门的入场前提。
认「办完了没有」,且四处都是基座现数:台账更新明细齐(有行、不缺必填)、
主数据(客户档案/联系人)写下的那几行不缺必填、且 S6 自己显式记了账
(ledger_update_confirmed_total 是写完之后才产生的数,不是「写过一笔就算数」)。
-->
<sentry id="s_ledger_updated">
<onPart id="op_lu" caseFileItemRef="ledger_update_confirmed_total"><standardEvent>update</standardEvent></onPart>
<ifPart id="if_lu"><condition>ledger_update_confirmed_total &gt; 0 and crm_coverage_ledger_update_rows &gt; 0 and crm_coverage_ledger_update_missing = 0 and customer_profile_missing = 0 and customer_contact_missing = 0</condition></ifPart>
</sentry>
<!-- ① 识别候选客户,铸造 round_ref(能算的不问人) -->
<humanTask id="S1_identify_candidates" name="识别候选覆盖客户"/>
<!-- ① 交人核对候选清单(增删改)—— 触达世界前的人环,不给 doneWhen -->
<humanTask id="S2_scope_confirm" name="覆盖范围确认:核对候选清单,增删改">
<entryCriterion id="ec_s2" sentryRef="s_candidates_drafted"/>
<itemControl><manualActivationRule id="m_s2"/></itemControl>
</humanTask>
<!-- ②③④ 读现状 → 缺口分析 → 制定经营计划(一项,不触达世界) -->
<humanTask id="S3_plan" name="读现状、缺口分析、制定关系经营计划">
<entryCriterion id="ec_s3" sentryRef="s_scope_confirmed"/>
</humanTask>
<!-- ②③④ 经营计划审批 —— 决策卡循环,通过或驳回+AI重做,不给 doneWhen -->
<humanTask id="S4_plan_approval" name="经营计划审批:通过或驳回(驳回则AI修订重提交)">
<entryCriterion id="ec_s4" sentryRef="s_plan_drafted"/>
<itemControl><manualActivationRule id="m_s4"/></itemControl>
</humanTask>
<!-- ⑤⑥(前半)交人执行经营动作并填回执 —— 触达世界,不给 doneWhen -->
<humanTask id="S5_execute_receipt" name="执行经营动作并填回执">
<entryCriterion id="ec_s5" sentryRef="s_plan_approved"/>
<itemControl><manualActivationRule id="m_s5"/></itemControl>
</humanTask>
<!-- ⑥(后半A)写回主数据:重算覆盖度、更新档案与候选状态(不触达世界,给 doneWhen) -->
<humanTask id="S6_writeback" name="写回主数据:重算覆盖度并更新档案">
<entryCriterion id="ec_s6a" sentryRef="s_receipt_confirmed"/>
</humanTask>
<!-- ⑥(后半B)留痕:逐条台账更新明细 + 本轮结论(不触达世界,给 doneWhen) -->
<humanTask id="S6_ledger_update" name="更新关系台账:留更新明细与结论">
<entryCriterion id="ec_s6" sentryRef="s_masters_written"/>
</humanTask>
<!-- ⑦ 复盘与完结:终局唯放行,必须人工激活 -->
<humanTask id="T_release" name="台账更新确认放行">
<entryCriterion id="ec_rel" sentryRef="s_ledger_updated"/>
<itemControl><manualActivationRule id="m_rl"/></itemControl>
</humanTask>
</casePlanModel>
</case>
</definitions>`;
/**
* 案例说明书 —— **只写整单一以贯之的事**(逐步做法归剧本,两处不重复写同一件事)。
*/
export const NARRATIVE = `§0 开案:**这一轮要经营哪些客户的关系,经营完把结果写回台账**。
入参四个顶层字段:
· \`trigger\`——**必有**,\`"monthly"\`(月度例行)或 \`"event"\`(事件驱动),翻成人读标签
写进 \`crm_coverage.verdict.trigger_label\`(报告上不摆系统内英文码),不用你自己判该归哪一类。
· \`eventType\`——仅 \`trigger:"event"\` 时可能有,四种之一:\`"new_customer"\`(新客户建立
合作)/ \`"key_person_change"\`(关键人离职/调岗)/ \`"major_deal"\`(重大商机启动)/
\`"t04_project"\`(攻坚项目 T04 立项)。同样翻成人读标签写进
\`crm_coverage.verdict.event_type\`(对照表见下方 S1 的 guide),\`trigger:"monthly"\`
时这一列留空。
· \`customers\`——本轮涉及的客户,**元素形状分两种**:①字符串,已有客户的 \`customer_code\`
(就是 L-02 客户主台账的 \`customer_id\`,同一个东西);②对象,**仅
\`eventType:"new_customer"\` 时出现**——这一刻这家客户还没有编码,对象里装的是
"新建一条 L-02 客户档案要用的字段"(公司名等),编码要等人在数据录入卡上现场给
(细节见 S1 的 guide,这是本轮唯一会用到的第二种卡)。**留空 = 月度例行,你自己
按台账规则从整个客户库识别本轮候选**。
· \`note\`——本轮背景说明(选填),帮你判断覆盖深度与经营侧重。
**成品是三样东西,缺一不可**:①一份**核对过的候选覆盖清单**;②一份**经审批通过的
关系经营计划**(逐个关键人的建议动作与优先级);③**写回 L-02/L-08 主数据的台账更新**
(覆盖度重算、决策链更新、联系人档案更新),每条更新都能在执行回执里找到依据。
**AI 承担信息汇总、记录与初稿生成,人负责判断与决策**:识别候选、缺口分析、排序计划、
收口写台账,都是你的活;核对候选清单、审批计划、到场执行、签字放行,四道都要人过。
**能算的不问人**:关键人是否覆盖(决策链节点比对)、覆盖度、排序,都调工具算,
你不临场拍;调 \`crm-relationship-coverage.recompute-score\` 和
\`crm-relationship-coverage.rank-key-persons\`,一批一次,别一条条调。
**六步走**:识别候选 → 人核对范围 → 制定计划 → 人审批(可能来回改一轮)→
人执行填回执 → 收口写台账 → 人签字放行。
【本单可能用到的另一种卡 —— 新客户建档,长这样,只在 S1_identify_candidates 这一项里
发生,\`customers\` 里出现对象形状的元素才发】新客户这一刻没有编码,**不能自己编一个**
(客户编码是主数据的主键,编错一次后面每一单都跟着错——这条纪律与 A1 quote-intake
建新客户档案那一步完全一样,照抄它已经验证过的机制):
{"kind":"entry","itemId":"S1_identify_candidates","ask":"这是位新客户,请核对建档信息并给客户编码",
"ledger":"customer.profile","key":{"customer_code":""},
"fields":[
{"key":"customer_code","label":"客户编码","required":true},
{"key":"name","label":"公司名","value":"青岛新星电子有限公司","required":true},
{"key":"cust_type","label":"客户类型","value":"模组厂"},
{"key":"industry","label":"行业","value":"车规电子"},
{"key":"region","label":"区域","value":"山东"}
],
"also":[{"title":"联系人档案","ledger":"customer.contact","key":{"customer_code":""},
"autoNote":"联系人编码由系统按顺序生成,不用你填",
"fields":[
{"key":"name","label":"联系人姓名","value":"王芳","required":true},
{"key":"title","label":"职位","value":"采购经理"},
{"key":"email","label":"邮箱","value":"wang.fang@example.com"}
]}]}
**只有 \`customers\` 里那个对象已经给出的字段才带 \`value\`**,没给的字段整格别写(界面自己
留白待填,别拿假值凑数);\`customer_code\` 从不给 \`value\`,标 \`required:true\`,人不填就
确认不了;对象里没给 \`contact\` 就不带 \`also\` 段(不是每次都已经知道具体联系人)。
**联系人编码不用你写**——自增列,基座在确认那一刻生成,写进 \`fields\` 也会被摘掉。
**人按下确认那一刻基座已经把这两张表直接落进草稿层**——你不经手,同一拍或下一拍都不许
再 \`write\` 一遍 \`customer.profile\`/\`customer.contact\`(写了会被"写了等于没写"拒回)。
一次只发一张,这张的回复到了、这家客户的编码定下来了,才轮到 \`customers\` 里下一个
对象形状的元素(如果还有的话)。
【这一单唯一的岔路口 —— 经营计划审批,长这样,无论哪一拍都照这个发,别自己另编一张】
计划审批不是一次性的:人可能驳回、要求你按他的意见改。**这一步只在 S4_plan_approval
这一项里发生**,发决策卡之前先在轨迹里找有没有已经答过、还没处理的 \`[card-reply]\`:
· **\`value\` 是 \`"approve"\`**——写 \`plan_approved_total=1\`,finish 这一项,别再发卡;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头**——都算驳回:\`"reject"\` 是点了固定
按钮、没写具体意见,**不许再发卡追问理由**(那两个按钮点出来的回复永远没有 note,
再问一遍是把人指向一扇打不开的门);以 \`"custom:"\` 开头的,\`note\` 就是他写的具体
意见,按这条意见改。两种都要对 \`crm_coverage.plan\` 做出至少一处实质性修订(同 PK
覆盖写,不开新计划项、不重新进入 sentry),然后**换一种问法**再发一张 decide 卡
(措辞不换会被系统当成同一张卡静默吞掉);
· **没有**——才发第一张 decide 卡。
{"kind":"decide","itemId":"S4_plan_approval","allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[{"value":"approve","label":"通过","recommended":true},
{"value":"reject","label":"驳回(不写具体意见)"}]}
三个 \`value\` 只有这三种形态:\`"approve"\`、\`"reject"\`、\`"custom:"\` 开头(人自己写的话)——
**别自己造第四种**。
【一路上要说话】负责人正看着这一单。**每一拍带一句 say** —— 说业务的话:
识别到几家候选、计划排了几个关键人、执行回执收了几条、覆盖度改了多少;不说系统词。
到了要找人的那一拍,先说清这一轮办完了什么、为什么现在需要他。
【怎么把一项干完 · 一拍连做】write 和 tool 都不会自动收步 —— 做完用 finish 宣告。
动作排进同一拍的 actions 串一口气给;只有发卡 / 提交(挂起)必须停拍。
**这一单常路 8-11 拍就该收口**(比 A1 多两道人环:候选核对 + 计划审批 + 执行回执 + 放行,
共四次;审批若被驳回会多出一到两拍)。
§Z 结案:AM Lead 在验收单上签字,系统把草稿合并进 L-02/L-08 正式版并发射
\`coverage-map\` 口 —— 这一轮的覆盖地图供下游读取(E05/E06 尚未实现,暂无消费者)。`;
/**
* 步骤剧本 —— 一个计划项一条;`doneWhen` 是「成果已存在」的判据,不是「该不该做」。
*
* 四处不给 `doneWhen`:`S2_scope_confirm`(交人核对触达世界)、`S4_plan_approval`
* (要人拍板,且这一项内部可能循环)、`S5_execute_receipt`(拜访/交流等 AI 做不到的线下动作)、
* `T_release`(签下去触达世界)—— 给了等于给引擎一把替人签字的钥匙。
*/
export const COVERAGE_STEPS = [
{
item: 'S1_identify_candidates',
intent: '按台账规则识别本轮候选覆盖客户,铸造本轮案号',
writes: ['crm_coverage.candidate', 'crm_coverage.verdict', 'customer.profile', 'customer.contact'],
/* 入参顶层字段不声明就会被整表段截断刀砍掉 —— 事件驱动那几条路全靠这四个字段;
case:openedAt 是铸 round_ref 的料(引擎注入的开案时刻,2026-08-22 起真的在作用域里) */
reads: ['trigger', 'eventType', 'customers', 'note', 'case:openedAt', 'customer.profile', 'customer.contact'],
view: 'coverage-scope',
tools: [],
/*
* **`_rows > 0` 独木难支,`_missing = 0` 也只顾得上半条命**(2026-08-22 两轮实测
* 逼出来的:事件驱动、customers 数组两个客户点名的入参)。
* 第一轮:模型写成的那一行 customer_name 是空的——`_missing = 0` 补上就治好了。
* 第二轮:模型干脆**没写第二颗客户那一行**(不是写了个空值,是那一行压根不存在)——
* `_missing` 只查「已经存在的行缺不缺字段」,查不出「该有的行还没生出来」;
* `_rows > 0` 更是从一开始就跟"两颗"这个数没关系,一行也算数。
* 两轮都在 S1/S2/S3/S4 四道全部放行,一路悄悄漏到 S5 提交闸才第一次被拦下。
* 治法只能是**把「该有几行」也摆进判据**:customers 数组点了几个名,
* 候选行就该有几条。
*
* **`count(customers)` 在入参没给这个键时 = 1,不是 0**(sfeel 的 count 对未绑定名
* flatten 出 `[undefined]`,长度 1 —— 包内 steps 测试用真 @sfeel 钉死了这三种形状):
* 月度例行(键不存在)走的是 `rows >= 1` 这一半,与前面 `rows > 0` 恰好同义,
* 所以行为对;但别把 `count(...) = 0` 那半句当成「留空时不约束」的开关 ——
* 它只在显式传了 `customers: []` 时才为真。
*/
doneWhen:
'crm_coverage_candidate_rows > 0 and crm_coverage_candidate_missing = 0 ' +
'and (count(customers) = 0 or crm_coverage_candidate_rows >= count(customers))',
guide: `第一件事:把这一轮**候选覆盖客户**列出来。**分两段**——先把每一位客户的编码
定下来,全部定下来了才写候选清单(候选清单不许分批写,写一半会被引擎当成这一步已经办完)。
**customers 里点了几个名,候选清单就该有几行**——事件驱动且点名的那几条路,
遗漏一颗客户等于让它从这一单里凭空消失,后面所有步骤都不会再补回来;
写完候选清单,自己数一遍行数是不是等于 customers 里点名的客户数,对不上就是漏写了。
【先铸造本轮案号 round_ref】格式固定:\`'COV-' + case:openedAt 按 YYYYMMDDHHmmss 格式化\`
(\`case:openedAt\` 就在你的作用域里,形如 "2026-08-22 08:47:58" —— 去掉分隔符即得;
你不必也不许自己编)。**不许从 \`crm_coverage.candidate\` 等正式表里已有的别单案号里挑**
—— 那是别的轮次的号,借了就把两轮的数据搅进同一个主键(A2 借号那一课的同款)。
**整单只有一个 round_ref**,写
\`crm_coverage.candidate\` / \`.plan\` / \`.receipt\` / \`.ledger_update\` / \`.verdict\`
时都用这同一个串 —— 造出两个号就是把一单裂成两单。
【第一段:把 customers 里每一项都定成一个确切的客户编码】
· \`customers\` 留空 —— **月度例行**,不必定编码,直接跳到"没点名"那条候选识别路径。
· 元素是字符串 —— 已经是编码,直接用,不必确认。
· 元素是对象(只在 \`eventType:"new_customer"\` 时出现)——**这是新客户,还没有编码,
发一张数据录入卡请人现场建档**,骨架照 NARRATIVE 那段"新客户建档"原样抄
(\`"kind":"entry"\`,\`ledger:"customer.profile"\`,\`key:{"customer_code":""}\`)。
一次只发一张,发完这一拍就结束(挂起);**人按下确认那一刻基座已经把公司档
(和给了的话,联系人档)落进草稿层**——不许你再 \`write\` 一遍这两张表。回复到了,
从轨迹里取人刚给的 \`customer_code\`,继续处理 \`customers\` 里下一项(还有的话)。
【第二段:全部编码定下来了,才做候选识别与写盘,一拍连做】
· 有确切编码的(月度例行没有这一条)—— **这一轮是事件驱动**,以这些编码为骨架,
逐个从 \`customer.profile\` / \`customer.contact\` 里取画像与联系人现状;
**候选清单只收这几个编码,一个不多、一个不少**——事件驱动点了名的,是这一轮
唯一该覆盖的客户;别顺手把台账里其他覆盖度低、决策链有空白的客户也一并纳入
(那是月度例行的活,这一轮不归它管)。已实测炸过:入参只点了 1 家,模型却在
交代里说"识别到 2 家候选客户",多出来的那一家台账里从没写过——人在核对卡上
看见的候选数量,得和候选清单里躺着的行数完全对得上,不能一个在嘴上一个在表里。
· \`customers\` 从一开始就留空的 —— **这一轮是月度例行**,按台账规则识别:优先选
覆盖度低(\`coverage_score\` 低或缺)、决策链有空白节点、或近期有商机/关键人变动
信号的客户。识别不出候选也要如实写 \`crm_coverage.verdict.summary\`,别硬凑几行充数。
【\`decision_chain\` 是什么形状,没有时怎么办】它是一个 JSON 结构:
\`{nodes:[{node, btLevel:"bt-01"|"bt-02"|"bt-03", required:boolean, contactCode, covered}]}\`。
这家客户若从没跑过 E04(\`customer.profile.decision_chain\` 空的),这一步不必初始化它 ——
留给 S3 那一步在做缺口分析时按标准三节点(bt-01 采购/供应链、bt-02 技术、bt-03 高层)
起草,这一步只管把候选客户与联系人现状读出来、把清单列好。
【写 \`crm_coverage.candidate\`,一颗客户一行】(rows 收数组)
round_ref、customer_code(**必填**,上一段定下来的那个)、customer_name(**必填**)、
cust_type(选填,\`customer.profile.cust_type\` 有值就直接抄它——新客户刚在录入卡上
给过;没有才按 \`grade\`/\`profile_tags\` 猜,如"Tier1 OEM"/"模组厂"/"方案商"/"贸易商"/
"终端",猜不出留空)、bt_level(选填,ASCII \`bt-01\`/\`bt-02\`/\`bt-03\` 之一,按这家
客户目前哪个层级的联系人最缺覆盖来定)、status(选填,ASCII \`pending\`,本步一律先写
这个值)、basis(选填,一句纳入依据,如"覆盖度已 40 天未更新"或"客户点名,商机联动"或
"新客户建档,首轮覆盖")、depth(选填,ASCII \`light\`/\`standard\`/\`deep\` 之一,按 basis
的紧迫程度定)、origin(固定写 \`ai\`)。
【同一拍写 \`crm_coverage.verdict\`(一行)】round_ref、trigger_label(**翻成人读标签写,
不写入参那个英文码**——\`"monthly"\`→"月度例行"、\`"event"\`→"事件驱动";报告上不摆系统内
英文名)、event_type(\`trigger:"event"\` 时把入参 \`eventType\` 也翻成人读标签写进来——
\`new_customer\`→"新客户建立合作"、\`key_person_change\`→"关键人离职/调岗"、
\`major_deal\`→"重大商机启动"、\`t04_project\`→"攻坚项目(T04)立项";\`trigger:"monthly"\`
时这一列留空)、note(入参 \`note\` 原样抄,没有留空)、summary 一句人话(本轮识别到几家
候选、依据是什么)。写完 finish 这一项。`,
},
{
item: 'S2_scope_confirm',
intent: '交人核对候选清单:增删改,确认本轮真正要覆盖谁',
writes: ['crm_coverage.verdict'],
reads: ['crm_coverage.candidate'],
view: 'coverage-scope',
tools: [],
/* 不给 doneWhen —— 交人核对触达世界(manualActivation),引擎不替人收 */
guide: `候选清单起草好了,**该 AM Lead 核对了** —— 这是本活动第一张执行单,零输入。
工作区摆的是候选清单的报告(编辑闸开着就能增删改),他在那份件上核对:AI 没识别到但
他了解有价值的客户,他会加一行;AI 纳入了但实际不该覆盖的,他会删掉;识别错的信息,
他会就地改。
这一步标了人工激活:发卡就算启动,不必单发 start;人交回后你自己 finish。
【照这个骨架发,别少字段】(卡面是纯文本,steps/standard 里别写星号记号,别绑 \`blocks\`
或 \`fields\` —— 整份清单在工作区那份件上,执行卡零输入)
{"kind":"human","itemId":"S2_scope_confirm","workOrder":"execution","card":{
"title":"覆盖范围确认:本轮共 <N> 家候选客户",
"ask":"AI 已按台账规则识别出 <N> 家候选客户,请核对确认。",
"steps":["工作区的件上核对候选清单:客户编码与客户名称是必填,缺一条这一单走不到经营计划那一步",
"加一行 = 额外纳入(AI 没识别但你了解有价值的客户)",
"删一行 = 排除(AI 纳入了但实际应排除的客户)",
"改单元格 = 修正 AI 识别的内容 —— 客户类型、BT 层级、纳入依据都能改",
"改完保存,回这张卡提交"],
"standard":"候选清单里每一行客户编码、客户名称都齐;该纳入的都纳入、该排除的都排除了。",
"basis":[{"k":"触发口径","v":"<照 crm_coverage.verdict 刚写的 trigger_label/event_type 说,如
「月度例行」或「事件驱动:重大商机启动」>"},
{"k":"AI 识别","v":"<N> 家候选客户"}]}}
发完这一拍就结束了(发卡挂起)。
【人交回之后】这一步你只做两件事,然后 finish:
① 写 \`crm_coverage.verdict.scope_confirmed_total = 1\`;他在件上撤掉了行或加了行的话,
下一步的缺口分析、计划制定按当下 \`crm_coverage.candidate\` 的实际行数走,不必你重写。
② finish 这一项。**别在这一拍做缺口分析** —— 那是下一步的活,这里做了也白做。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着,
卡回来时客户编码/客户名称一定是齐的(或那几行已被他撤掉)。`,
},
{
item: 'S3_plan',
intent: '读覆盖现状、缺口分析、制定关系经营计划',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.candidate'],
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',
guide: `候选范围定了,**现在读现状、找缺口、排计划** —— 固定三拍,一拍一件事,别在同一拍里
把「调工具」和「写台账」串在一起(**工具调用后同拍其余动作作废,结果要下一拍才看得见**;
串了也是白串,只会多烧一拍)。这一步只需要调工具**各一次**,拿到结果后不要再调第二次。
**这一步只管 \`crm_coverage.candidate\` 里躺着的那几家,一家都不能多**——\`reads\` 里
还带着 \`customer.profile\`/\`customer.contact\` 整表,那是给你查这几家候选客户各自的
决策链与联系人现状用的**参考资料**,不是"顺手也把看见的其他客户一起排上计划"的许可。
已实测炸过:候选清单里明明只有 1 家,写出来的计划却多了一家台账里压根不存在于候选清单
的客户(那家客户是**从 customer.profile/customer.contact 整表里"看见"的**,和这一轮
候选范围毫无关系)——动手前先把候选清单的 \`customer_code\` 抄一遍,写计划时逐条核对
是不是都在这张抄出来的清单里,不在的一律不许写。
【第一拍:缺口分析,只调工具,别的什么都不写】把本轮全部候选客户(读
\`crm_coverage.candidate\`)一次性打包,调一次
\`crm-relationship-coverage.recompute-score\`,入参:
\`\`\`
{"customers":[
{"customerCode":"CU-0142",
"decisionNodes":[
{"node":"bt-01","btLevel":"bt-01","required":true},
{"node":"bt-02","btLevel":"bt-02","required":true},
{"node":"bt-03","btLevel":"bt-03","required":true}],
"contacts":[
{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":false,"influence":"","relationship":0}]}
]}
\`\`\`
· \`decisionNodes\`:这家客户若已有 \`customer.profile.decision_chain\`,原样照抄它的节点;
没有(新客户/首次跑 E04)就照抄上面这三行(**node 与 btLevel 写同一个值**,
\`bt-01\`/\`bt-02\`/\`bt-03\`,一个字符不许错、不许留空 —— node 留空会让出参里那个节点
变成 \`null\`,缺口分析就废了一条)。
· \`contacts\`:这家客户名下**所有**已知联系人(读 \`customer.contact\`),逐人填
\`contactCode\`/\`influence\`/\`relationship\`/\`keyPerson\`;\`decisionNode\` 按人现有的
\`customer.contact.decision_node\` 填,没有就按职位/角色估一个最贴近的层级(如「采购部」
估 bt-01),都判不出就填 \`"bt-01"\` 兜底 —— **这一格不许留空**,留空这个人就永远配不上
任何节点、白算了这一趟。空缺字段(如 \`influence\` 还没定过)照现有值填,是空字符串
或 0 都行,工具认得。
多家候选客户就都塞进同一个 \`customers\` 数组里,**一次调完,不要一家调一次**。
调完这一拍就结束(工具出参要下一拍才看得见)。
【第二拍:排序,只调工具,别的什么都不写】把上一拍 \`results\` 里出现过的**所有联系人**
(不止缺口节点上的那几位)一次性打包,调一次 \`crm-relationship-coverage.rank-key-persons\`,
入参 \`{"contacts":[{"contactCode":"...","influence":"...","relationship":0}, ...]}\`——
**批量调,不要一人调一次**。调完这一拍就结束。
【第三拍:写计划,一拍连做】拿着前两拍的工具出参,**一次都不要再调工具**,直接写:
① 逐家候选客户看第一拍的 \`gapNodes\`:**gapNodes 里的每一个缺口节点,都要在计划里
至少配一个关键人经营动作**——就算这个节点上目前一个联系人都没有覆盖到(\`coveredNodes\`
是空的、\`coverageScore\` 是 0),那正是这一步该管的事:挑这家客户名下**已知的、
离这个节点最近的联系人**(按 \`decisionNode\` 匹配,匹配不上就挑影响力最高的那位)
写一条「去覆盖这个节点」的动作。**\`gapNodes\` 全员缺口是正常结果,不是异常,
不需要回去重调工具确认。**
② **写 \`crm_coverage.plan\`,按第二拍的 rank_score 降序、一个关键人一行**(rows 收数组;
模版不排序,你必须已经按序写)。round_ref、contact_code(**必填**)、customer_code
(**必填**)、customer_name(选填)、contact_name(选填)、action(**必填**,具体到能
执行的建议动作,如"下月中拜访技术总监,带 XX 认证报告"而不是笼统的"拜访")、priority
(**必填**,ASCII \`high\`/\`medium\`/\`low\`,照第二拍算出的档位抄)、rationale(**必填**,
说清为什么是这个人这个动作,引影响力与亲密度差值,或"该节点目前空白,此人是最接近的
候选")、influence(**必填**,写计划这一刻的快照,照抄联系人当前的 influence,没有就写
\`"low"\`)、relationship_gap(**必填**,第二拍算出的数)、rank_score(**必填**,第二拍
算出的数)、target_date(**必填**,具体日期,没有时间点的建议不算数)。
③ **同一拍,给每条计划配一行 \`crm_coverage.receipt\` 骨架**(这一步只写骨架,完成状态那几列
留空,S5 那一步才由人填):round_ref、contact_code(与 plan 同一个,**必填**)、
customer_code(选填,照抄)、customer_name(选填,照抄 —— 执行回执工单的「对象」列长
"客户名 · 关键人",两个名字都从这里来)、contact_name(选填,照抄)、action(选填,
照抄 plan 的 action,供执行回执工单只读展示)、target_date(选填,照抄)。
completion_status / result_summary / customer_feedback / key_person_change
**这一步一格都不写**。
④ 若某候选客户的 \`cust_type\`/\`bt_level\` 在 S1 那步没判出来,顺手补写
\`crm_coverage.candidate\` 的这两列(同 PK 覆盖写)。
写完这一拍就结束(不必你 finish)——\`crm_coverage_plan_rows > 0 and
crm_coverage_plan_missing = 0\` 一旦为真,引擎自己收这一步。`,
},
{
item: 'S4_plan_approval',
intent: '经营计划审批:通过就往下走,驳回则按意见修订后换一种问法再提交',
writes: ['crm_coverage.plan', 'crm_coverage.receipt', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan'],
view: 'coverage-plan',
tools: [],
/* 决策卡循环 + 要人拍板 —— 不给 doneWhen,引擎不许替人收 */
guide: `**动手之前先在轨迹里找一行 \`[card-reply]\` 带这一项的答复:**
· **没找到** —— 这是第一次,跳到下面【发一张决策卡】那一段;
· **\`value\` 是 \`"approve"\`** —— 写 \`crm_coverage.verdict.plan_approved_total = 1\`,
finish 这一项,**不许再发卡**;
· **\`value\` 是 \`"reject"\` 或以 \`"custom:"\` 开头** —— 都算驳回,跳到下面【驳回之后】
那一段,修订计划再重新发卡。
【这一步办完了没有,看一个数】\`plan_approved_total\`:它写成 1 就是这一步办完了,
引擎不会再把这一项交给你(它归零 doneWhen 帮不上忙,只有你自己 finish)。
【发一张决策卡,照这个骨架】(选项少于两个基座拒发;\`allowCustom\` 别漏 —— 少了它,人只有
两个固定按钮可点,连意见都没地方写;\`ask\` 每一轮都要按下面的规则换措辞)
{"kind":"decide","itemId":"S4_plan_approval",
"allowCustom":true,
"ask":"经营计划已算出,共 <N> 条,覆盖 <M> 家客户的关键人,请审批。",
"options":[
{"value":"approve","label":"通过","recommended":true,"consequence":"计划进入执行阶段,交人去落实"},
{"value":"reject","label":"驳回(不写具体意见)","consequence":"AI按自己的判断复核一遍计划,重新提交审批"}],
"basis":[{"k":"覆盖客户","v":"<M> / <本轮候选总数> 家已各有至少一条计划"},
{"k":"排序依据","v":"影响力 × 亲密度缺口"},
{"k":"想说明理由","v":"不点上面两个按钮,直接在下方输入框写你的意见再提交"}]}
发完这一拍就结束了(发卡挂起)。
【驳回之后 —— 不管有没有具体意见,都只处理这一轮,不许再追问一遍】
① \`value\` 以 \`"custom:"\` 开头 —— \`note\` 就是他写的具体意见,**按这条意见**修订
\`crm_coverage.plan\`(同 PK 覆盖写对应的行,不开新计划项、不重新进入 sentry)。
② \`value\` 是 \`"reject"\`(点了固定按钮,没写具体意见)—— **不许再发卡追问理由**
(那两个按钮点出来的回复永远没有 note,再问一遍是把人指向一扇打不开的门,
会被同一句话拒回,连问出真话的机会都没有)。改成你自己复核:排序有没有明显不合理、
有没有遗漏更紧要的缺口节点、理由那一句站不站得住,尽量改得更有说服力;复核后**至少要有
一处实质性变化**(改排序、换动作、补理由,不能原样重发)。
③ **改了 \`action\` 这一列的话,\`crm_coverage.receipt\` 同一行的 \`action\` 也要跟着改**
(同 PK——\`round_ref\`+\`contact_code\`——覆盖写)。receipt.action 是 S3 那一拍从
plan.action **抄**过来的展示副本(执行回执工单直接读它、只读展示),这里只改了
plan 不改 receipt,S5 那一步人看到的还是驳回前的旧动作——已经实测过:审批来回改了
三稿,receipt 里摆的还是第一稿,推进到执行回执时对不上人刚通过的那一版。
④ **重新发卡的 \`ask\` 必须换一种说法**,带上修订轮次与改了什么,例如
"已复核并修订(第2稿:调整了 CU-0142 的优先级,补充了理由)请再次审批"——
措辞不换,系统会把它当成同一张卡:前两次静默吞掉(人看不到新卡),第三次直接拒。
**每一轮的 \`ask\` 都要与上一轮明显不同**,不止改个数字。
⑤ 再走一遍【发一张决策卡】那一段(用新措辞,\`allowCustom\` 仍然要给),然后这一拍结束
(发卡挂起)。
**这一步只在这一项里循环,不许把驳回意见带去改别的项**(S1/S2/S3 写下的东西不动)。`,
},
{
item: 'S5_execute_receipt',
intent: '交人执行经营动作,回填执行回执',
writes: [],
reads: ['crm_coverage.receipt'],
view: 'coverage-receipt',
tools: [],
/*
* 拜访 / 技术交流等线下动作 AI 做不到 —— 不给 doneWhen。
* **这一步没有任何记账旗**(2.0.2 退役 receipt_confirmed_total,两轮活单熔断实测):
* 旗是模型写的,S4 一过它顺手写旗就坚信 S5 已办完,连续五拍
* [写旗 + finish 被 worldTouched 拒] 熔断。回执四列全是人在件上填的,
* 收口那道门读的是基座数(`_rows`/`_missing`)—— 模型无账可记,也就无处抢跑。
*/
guide: `计划审批通过了,**该 AM 出场了** —— 这是本活动第二张执行单,零输入。
触达客户的那一下由人做:拜访、技术交流、价值传递这些线下动作 AI 做不到,
工作区摆的是执行回执工单(逐个关键人一行,计划动作只读展示,回执四列可填)。
这一步标了人工激活:发卡就算启动,不必单发 start。**这一步不写任何台账** ——
回执四列由人在件上填、由基座直接落草稿层,你只有两个动作:发卡,和人交回后 finish。
**这一步「人被问过没有」不用你判断 —— 【当前这一步】标题下面那行引擎事实写得明明白白**
(「这一单里已经真的给它发过卡…人已经被问过了」或「这一单里从没给它发过卡」),照它分两条路:
· 那行说**「从没给它发过卡」** —— 你唯一合法的动作就是照下面骨架发卡,发完这一拍结束(挂起)。
**别去轨迹里数 \`[card-reply]\` 自己判断**:S2 的执行单、S4 的决策卡都会在轨迹里留
\`[card-reply]\`,它们不是这一步的 —— 实测两轮连拒都是把 S4 审批的答复当成了 S5 的回执,
凭空宣布「执行回执已交回」。引擎那行事实比你对轨迹的读法准,以它为准。
· 那行说**「人已经被问过了」** —— 人交回后直接 finish 这一项,一个字都不用写
(回执数据人已经在件上填好、基座已落草稿层;**别在这一拍收口写台账** ——
重算覆盖度、写回主数据是下一步的活)。
【照这个骨架发,别少字段】(执行卡零输入,别绑 \`blocks\` 或 \`fields\`)
{"kind":"human","itemId":"S5_execute_receipt","workOrder":"execution","card":{
"title":"经营计划执行回执:共 <N> 项动作",
"ask":"经营计划已审批通过,请执行并回填回执。",
"steps":["每项动作执行后,在工作区的件上逐项回填:完成状态、结果摘要为必填,客户反馈选填",
"关键人变动如实回答「是/否」;若填「是」,请在结果摘要里说清具体变动是什么",
"确实没有变动的,「关键人变动」这一格要写「无变动」,不能留空",
"改完保存,回这张卡提交"],
"standard":"每一项动作的完成状态与结果摘要都填了;关键人变动逐项如实标注,不遗漏。",
"basis":[{"k":"本轮动作数","v":"<N> 项"}]}}
发完这一拍就结束了(发卡挂起)。
【这张卡一单只发一次】人交回来之后不许再发同一张卡 —— 缺必填有提交闸兜着。`,
},
{
item: 'S6_writeback',
intent: '写回主数据:按回执重算覆盖度,更新联系人/客户档案与候选状态',
writes: ['customer.profile', 'customer.contact', 'crm_coverage.candidate'],
reads: ['crm_coverage.receipt', 'crm_coverage.plan', 'customer.profile', 'customer.contact'],
view: 'coverage-ledger-update',
tools: ['crm-relationship-coverage.recompute-score'],
/*
* 四个数全是基座现数:重算分与档案都写齐了才算办完。
* 收口从一项拆成两项(2.0.4 结构治):从前「三拍纪律」写得再明白,模型照样把
* 四张表挤进一串,串尾最费字数的 ledger_update 被截成空数组挨拒(两轮实测同一处)。
* 拆开之后每项的剧本只摊自己那几张表 —— 串想长也长不起来。
*/
doneWhen:
'customer_profile_rows > 0 and customer_profile_missing = 0 and customer_contact_rows > 0 and customer_contact_missing = 0',
guide: `回执都收齐了,**现在写回主数据** —— 固定两拍,别把「调工具」和「写台账」串进
同一拍(工具调用后同拍其余动作作废,结果要下一拍才看得见)。
**这一步不碰 \`crm_coverage.ledger_update\`** —— 留更新明细是下一步(S6_ledger_update)
的活,这一步只管把主数据写对。
【第一拍:重算,只调工具,别的什么都不写】逐个联系人按回执推导新的 relationship/influence
(草稿,先想清楚,还不落库):\`completion_status\` 为 \`done\` 且关系明显推进的,
\`relationship\` 上调(不超过 5);\`partial\`/\`skipped\` 的原样不动或视回执具体内容微调。
**调不出结论就别改** —— 没有回执支持的调整不许写。把本轮涉及的候选客户**一次性打包**,
调一次 \`crm-relationship-coverage.recompute-score\`:
\`decisionNodes\` 照抄 \`customer.profile.decision_chain\` 现有的节点(\`node\`/\`btLevel\`/
\`required\` 三样都要,一个字符不许错);\`contacts\` 是这家客户名下**所有**已知联系人,
**逐人五个字段都要给全**——\`contactCode\`/\`decisionNode\`(按 \`customer.contact
.decision_node\` 填)/\`keyPerson\`(这轮判断的关键人标记)/\`relationship\`(推导后的新值)/
\`influence\`(推导后的新值)。
\`\`\`
{"customers":[{"customerCode":"CU-0142",
"decisionNodes":[{"node":"bt-01","btLevel":"bt-01","required":true}, ...],
"contacts":[{"contactCode":"CU-0142-01","decisionNode":"bt-01","keyPerson":true,
"relationship":4,"influence":"high"}]}]}
\`\`\`
**\`keyPerson\` 这一格漏了,工具会把这个人当成"不存在",这个节点当场判空**——已经实测炸过:
同一批入参只差这一个字段,coverageScore 从 17/33 变成 0,跟台账里明明白白写着的
"bt-01 已覆盖"对不上;模型因此怀疑结果、来回改了又调、调了又改,一单纯为这一步烧了 26 次
工具调用、烧穿了 500k token 预算,\`crm_coverage.ledger_update\` 一行没写成就 exhausted 了。
拿到 \`results\`(逐家 coverageScore/coveredNodes)——**一次调完,不要一家调一次,更不要见
结果与预期不符就反复重调**:它是纯工具,同样的输入永远同样的结论,算出来的覆盖度比预想低,
先检查自己传的 \`contacts\` 是不是漏了字段,而不是重调一遍赌下次会不一样(不会)。
调完这一拍就结束。
【第二拍:只写这两张,别的表这一拍不碰】
· **写 \`customer.contact\`**(对应联系人行,同 PK 覆盖写):**碰过的每一行,
\`influence\` / \`relationship\` / \`key_person\` 三列一列都不许缺**(基座数缺必填
只看本单写下的行 —— 你写了这一行却漏了其中一列,\`customer_contact_missing\`
就大于零,放行门永远打不开):\`influence\` 写推导后的评级(没变化就照现值抄一遍)、
\`relationship\` 写推导后的新值(没变化照现值抄)、\`key_person\` 照这轮判断写;
**本单新建档的联系人**(录入卡只落了姓名/职务/邮箱)这三列由你首评:
\`influence\` 按职务估(总监/VP 级 high、经理级 medium、其余 low)、
\`relationship\` 按实际接触定(还没接触过写 1)、\`key_person\` 如实判断;
\`role\`/\`decision_node\`/\`comm_pref\` 若这轮有新信息一并补;
\`change_log\` 追加一句本轮变动(离职/调岗从回执的 \`key_person_change\` 抓,
没有变动不必写这一列)。
· **写 \`customer.profile\`**(对应客户行,同 PK 覆盖写):\`coverage_score\` = 工具
算出的新分数(**必填**);\`decision_chain\` = 更新后的节点覆盖状态(**必填**,
整个数组重写,不是增量合并);\`interaction_timeline\` 追加本轮的互动条目(选填,
没有新增互动的客户不必写这一列)。
· **把办完的候选行状态置 \`covered\`**(写 \`crm_coverage.candidate\`,同 PK 覆盖写,
只带 status 一列):回执里有 \`done\` 动作的客户置 ASCII \`covered\`;
全部 \`skipped\` 的留 \`pending\` 如实 —— 清单上那枚状态徽是给人看的,
一轮跑完还全写着「待覆盖」就是界面在撒谎。
写完这三张,这一步就办完了 —— 不必你 finish,\`doneWhen\` 会在主数据写齐那一刻自动收步,
留更新明细归下一步(S6_ledger_update),这一拍到此为止。`,
},
{
item: 'S6_ledger_update',
intent: '留痕收口:逐条台账更新明细 + 本轮覆盖结论',
writes: ['crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.receipt', 'customer.profile', 'customer.contact'],
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',
guide: `主数据已在上一步写回,**这一步只写两张表、一拍连做**:先写更新明细
(它最费篇幅,放在串首),再写结论。**上一步那三张表(contact/profile/candidate)
这一拍不许再写一遍** —— 再写一遍原值只会被当空转拒掉。
· **写 \`crm_coverage.ledger_update\`,每条主数据更新对应一行**(rows 收数组,
**数组不能是空的** —— 第二拍写的每一处真实变化,这里都要有一行对应):
round_ref、update_seq(**必填**,本轮内从 1 递增)、ledger_label(**必填**,人读名,
如"客户主台账·关系覆盖度")、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")。
**没有真实变化的字段不必开一行** —— 明细表只记「真改了什么」。
每一处真实变化的依据都在回执与上一步写下的新值里,对照着写,别凭记忆。
· **写 \`crm_coverage.verdict\`**(同一拍接着写):ledger_update_confirmed_total = 1、
covered_customer_total = 这一轮覆盖度提升或已达标的客户数、
key_person_covered_total = 这一轮标记为 key_person 的联系人数、summary
补一句人话(这一轮覆盖了几家客户、覆盖度平均涨了多少)。
写完 finish 这一项 —— 或者不必你 finish,\`doneWhen\` 会在必填列都齐的那一刻自动收步。`,
},
{
item: 'T_release',
intent: '台账已收口,提交放行交 AM Lead 验收;被打回就按意见改完再交',
/* 被打回重做时要改的就是这三张 —— 不声明就写不进去(写的授权面是台账声明) */
writes: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.verdict'],
reads: ['crm_coverage.plan', 'crm_coverage.ledger_update', 'crm_coverage.receipt'],
view: 'coverage-ledger-update',
tools: [],
/* 放行触达世界,必须人按一下 —— 引擎不自动收它 */
guide: `台账更新明细齐了、主数据缺项也补齐了,**可以走放行了**
(T_release 人工激活:动它就算启动,不必单发 start)。
【**先看一眼轨迹里有没有「验收被打回重做」**】有的话,这一拍**不是**再提交一遍 ——
人已经看过这份成果并把它退回来了,原样递上去他看见的还是刚被他打回的那一份
(基座那道闸也会当场拒:「打回之后台账一个字没改」)。照他的意见改**台账本身**,
改完这一拍再 judge + submit:
· 他说**计划的理由空/不实** → 改 \`crm_coverage.plan\` 的 \`rationale\`(同 PK 覆盖写),
把影响力评级、亲密度差值、决策链缺的是哪个节点写进去,一条一条落实;
动作不具体的连 \`action\`/\`target_date\` 一起改。
· 他说**台账更新没依据 / 数不对** → 改 \`crm_coverage.ledger_update\` 的
\`evidence_ref\`(点名是哪条回执支持它)或 \`before_value\`/\`after_value\`。
· 他说**结论没说清** → 改 \`crm_coverage.verdict.summary\`。
· 他**没写具体意见**(只点了重做)→ 自己把成果复核一遍,挑最站不住的那处改实
(理由最空的那条计划、依据最含糊的那条更新),**至少改对一处**;
实在判断不出他要什么,发一张零输入执行工单问清楚,别原样重提。
**改的是值,不是数**:同一行覆盖写,别新开行、别把行数改了 —— 那会把收步判据弄假。
【没被打回(或已按意见改过了)】先把裁量判据裁掉,然后 submit,同一拍连做:
· judge criterion="每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算" ——
逐条看 \`crm_coverage.ledger_update\` 的 \`evidence_ref\`:说得出具体哪条回执支持这条
更新就 satisfied=true;有一条说不清来路的,satisfied=**false**,rationale 逐条写清,
交签字的人定。
· judge criterion="更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变" ——
看每条数值型更新的 before/after 幅度是否与回执的完成程度相称。
· judge criterion="更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人" ——
核对 customer_code/contact_code 与回执主体是否一致。
**裁成不满足照样能提交** —— 理由连着卡一起交给签字的人。criterion 照判据名整句原样抄,
写别的名字对不上。
这一单没有分支去向 —— 全程线性,只发一口 \`coverage-map\`,不必你选出口。
终局唯放行:你不能自己宣布完成。`,
},
];
/**
* 阶段 —— 进度条的刻度,只给人看。除放行外每个计划项恰属一个阶段;
* 放行那一格由基座统一加,人看到的是 x/4。
*/
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'] },
];
@@ -0,0 +1,11 @@
/**
* E04 · 关系覆盖与关键人经营 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。
*
* 这个活动没有 `run`。案例活动的推进由 Loop 驱动,行为全部写在清单里:
* 案例模型 + 说明书 + 步骤剧本 + 两个纯工具 + 三条技能 + 台账声明 + 四张 HTML 报告模版。
* 这个文件之所以还存在,是因为清单的 `entry` 指着它 ——
* 它是**被静态审扫描的产物文本**(查 import 与全局引用,证明活动包零环境能力)。
*/
import { defineActivity } from '@sdk';
export default defineActivity({ name: 'crm-relationship-coverage' });
@@ -0,0 +1,584 @@
/**
* 活动包清单 —— E04 关系覆盖与关键人经营(案例活动)。
*
* 业务出处:`docs/SS01-客户与市场/e04-html/E04-活动流程与工单说明.md`(7 步业务链条,
* AI 承担信息汇总/记录/初稿生成,人负责判断与决策,收成 2 张执行工单 + 2 张验收/决策工单)、
* `docs/SS01-客户与市场/Demo-E04-I01-Activity台账关联与设计.html`(L-02 客户主台账 /
* L-08 客户联系人台账的读写关系)。
*
* 这是本仓库第一个不属于 `quote-*` A1-A7 报价管线的活动:触发方式是月度例行 / 事件驱动
* 而非来件邮件,核心读写对象是共享主数据 `customer.profile`/`customer.contact`。
*
* 两处与 A 系列的刻意不同,写在这里免得下一个读者当成疏漏:
* ① `customer.profile`/`customer.contact` 已由 A1(quote-intake)声明 `kind:'master'`
* 并持有 `customer_code`/`name` 等基本列 —— 这里只**追加**本活动要用的新列
* (`schema-merge.ts` 的声明合并是纯 add-column,不冲突,不需要重复声明已有列)。
* ② 「验收工单1」(经营计划审批)是**非终局**的通过/驳回+AI重做循环 ——
* `issueAcceptance` 硬绑定终局放行、一个任务只能发一次,不能拿来做中间验收点,
* 所以这里用 `decide` 决策卡实现(S4_plan_approval),真正的终局验收放行是
* 「验收工单2」(T_release,台账更新确认)。
*/
import type { ActivityManifest, DmnModel } from '@contracts';
import { activityIdSchema } from '@contracts';
import { COVERAGE_SKILLS } from './skills.ts';
import { CMMN_XML, NARRATIVE, COVERAGE_PHASES, COVERAGE_STEPS } from './case-model.ts';
import { COVERAGE_SCOPE, COVERAGE_PLAN, COVERAGE_RECEIPT, COVERAGE_LEDGER_UPDATE } from './views/report.ts';
const D = 'crm_coverage';
const ACTIVITY_ID = activityIdSchema.parse('crm-relationship-coverage');
/**
* 没有判定表 —— 覆盖度与排序是**连续加权算术**(BT 层级权重 × 覆盖率、
* 影响力权重 × 亲密度缺口),不是离散分支,不适合 DMN 命中表,交给两个纯工具算
* (见 `apps/api/src/app/tools/first-party.ts` 的
* `crm-relationship-coverage.recompute-score` / `.rank-key-persons`)。
* `dmn` 在清单里是必填字段,空表即可 —— 静态审不对 DMN 表数量做任何要求。
*/
const EMPTY_DMN: DmnModel = { drd: [], tables: [] };
export const manifest: ActivityManifest = {
identity: {
activityId: ACTIVITY_ID,
name: 'E04 · 关系覆盖与关键人经营',
packageVersion: '2.0.9',
category: '客户关系经营',
sdkVersion: '0.1.0-m5',
checksum: 'dev-checksum-crm-relationship-coverage',
},
/* ── 契约七件 ────────────────────────────────────────────────── */
contract: {
activityId: ACTIVITY_ID,
version: '1.0.0',
input: {
/*
* 不是来件解析(没有唯一「这封信」),是触发参数。§六 E04 业务说明与
* `Demo-E04-I01-Activity台账关联与设计.html`(L-02/L-08 完整字段表)两份文档核对
* 后按用户明确要求做成**两级下拉 + 一个数组**:
* ① `trigger` 必填 —— 月度例行 / 事件驱动,用户明确要求这一级必须是显式的
* 下拉框(不接受"从数组是否点名反推"这种省略,那样选项与数组状态一旦不一致
* 就是一处会撒谎的地方);
* ② `eventType` 选填,只在 `trigger:'event'` 时在测试台表单里现身(靠
* `showWhen` 这个测试台通用能力),值就是业务说明里那四种事件;
* ③ `customers` 数组,元素两种形状二选一:字符串 = 已有客户的 `customer_code`
* (业务说明里这就是 L-02 的 `customer_id`,同一个概念,本仓库沿用既有列名
* 不改叫法);对象 = **仅 `eventType:'new_customer'` 时使用**,这一刻这家
* 客户还没有编码(新客户建档要等人在录入卡上现场给),数组元素就相当于
* "新建一条 L-02 客户主台账记录所包含的字段"。录入卡会把这些字段落进
* `customer.profile`/`customer.contact` —— 那几列(name/industry/region/
* title/email)本版起**都在下方 declarations.ledger 里显式声明**
* (授权面是台账声明;从前只在说明书里出现、声明里没有,是一处会撒谎的注释)。
* `items` 不声明单一形状 —— 字符串与对象二选一,声明成 object 会让测试台的
* 类型提示(object[])与首选用法(字符串数组)相悖。
*/
data: {
type: 'object',
required: ['trigger'],
properties: {
trigger: {
type: 'string',
enum: ['monthly', 'event'],
enumNames: ['月度例行(常规覆盖轮次)', '事件驱动'],
title: '触发方式',
},
eventType: {
type: 'string',
enum: ['new_customer', 'key_person_change', 'major_deal', 't04_project'],
enumNames: ['新客户建立合作', '关键人离职/调岗', '重大商机启动', '攻坚项目(T04)立项'],
title: '事件类型(仅事件驱动时选)',
showWhen: { field: 'trigger', equals: 'event' },
},
customers: {
type: 'array',
title:
'本轮涉及的客户 —— 已有客户填客户编码字符串;若事件类型是"新客户建立合作",' +
'改填新客户档案对象(公司名等 L-02 字段,不含客户编码——那一格由人在录入卡上现场给)。留空 = 月度例行,AI按台账规则从整个客户库筛选候选',
examples: [
['CU-0142'],
[{ name: '青岛新星电子有限公司', custType: '模组厂', industry: '车规电子', region: '山东', contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' } }],
],
},
note: {
type: 'string',
title: '本轮说明(选填,背景信息 —— 帮AI判断覆盖深度与经营侧重)',
examples: ['客户 CU-0142 启动新项目,商机金额较大,AM Lead 要求本轮优先覆盖其决策链'],
},
},
},
messageAllowed: true,
messageHint: 'AM Lead 可在此说明本轮覆盖的特殊要求、优先客户、或需要重点关注的关键人变动',
},
output: {
/*
* 单一必发口,无分支:E04 全程线性(①→②③④→⑤⑥→⑦),没有像 A1 那样
* 「有没有 C 类」这种要按序求值的岔路。E05/E06 在本仓库尚未实现,暂无消费者 ——
* 按「契约先立、实现后补」的纪律先声明,不因为没有下游就不发。
*/
mandatory: [
{
name: 'coverage-map',
description:
'本轮更新后的关系覆盖地图 —— 供未来 E05互动与拜访 / E06账户计划读取;当前代码库尚未实现下游活动,此口暂无消费者,仅按"载荷=已落地事实"的契约纪律预先声明',
payload: {
type: 'object',
required: ['roundRef', 'coveredCustomerTotal', 'keyPersonCoveredTotal'],
properties: {
roundRef: { type: 'string', title: '覆盖轮次编号' },
coveredCustomerTotal: { type: 'integer', title: '本轮覆盖客户数' },
keyPersonCoveredTotal: { type: 'integer', title: '本轮覆盖关键人数' },
},
},
},
],
selective: [],
},
/*
* 四份必交付物,逐一对应四张工单产出的成果。全部 `editableAtRelease: []`——
* 业务说明明文"验收工单只读展示,不可修改"(与 A1 放行时允许改 type_tag 是刻意对比,
* 不是遗漏:E04 的验收工单从设计上就没有"就地修订"这一环)。
*/
requiredDeliverables: [
{
key: 'coverage-scope',
label: '覆盖范围客户清单',
ledger: `${D}.candidate`,
ledgers: [`${D}.candidate`, `${D}.verdict`],
editableAtRelease: [],
},
{ key: 'coverage-plan', label: '关键人经营计划', ledger: `${D}.plan`, editableAtRelease: [] },
{ key: 'coverage-receipt', label: '执行回执', ledger: `${D}.receipt`, editableAtRelease: [] },
{
key: 'ledger-update',
label: '台账更新明细',
ledger: `${D}.ledger_update`,
ledgers: [`${D}.ledger_update`, `${D}.verdict`],
editableAtRelease: [],
},
],
/*
* 验收判据 —— **机检(L1/L2)只读基座数得出来的数**:`_rows`/`_missing` 基座现数,
* 与执行卡的提交闸同一个数同一套算法。L4 三条是纯裁量(有没有依据、数值合不合理、
* 有没有张冠李戴)——机器数不出「这条更新站不站得住」,那是人对结果负责的地方。
*/
acceptanceCriteria: [
/*
* 两条 L1 的「人过了没有」一半仍靠记账旗(scope_confirmed_total / plan_approved_total,
* 只有人回卡之后模型才记 —— 与 A1 的 confirmed_total 同款,规范 §5.3-4 背书的人环凭据);
* 但**每条都必须再钉一半基座数**:旗是模型写的,单独成谓词就是「机检只读基座数」
* 第 5 类违反的伪装形 —— 抢跑写旗那单(2026-08-22 活单熔断)正是踩着这半边过的门。
*/
{
key: 'scope-confirmed',
label: '覆盖范围已经 AM Lead 核对确认(执行工单1已交回,清单有行且必填齐)',
level: 'L1',
predicate: `scope_confirmed_total > 0 and ${D}_candidate_rows > 0 and ${D}_candidate_missing = 0`,
},
{
key: 'plan-approved',
label: '关系经营计划已经 AM Lead 审批通过(验收工单1已过,不处在驳回待改状态)',
level: 'L1',
predicate: `plan_approved_total > 0 and ${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
key: 'plan-complete',
label: '经营计划逐条完整:关键人、建议动作、优先级、理由、时间点都有',
level: 'L2',
predicate: `${D}_plan_rows > 0 and ${D}_plan_missing = 0`,
},
{
key: 'receipt-matches-plan',
label: '每条计划都收到执行回执,且完成状态、结果摘要、关键人变动均已填写',
level: 'L2',
predicate: `${D}_receipt_rows > 0 and ${D}_receipt_rows = ${D}_plan_rows and ${D}_receipt_missing = 0`,
},
{
key: 'ledger-update-complete',
label: '台账更新明细逐条完整:每条都写明台账、字段、更新前、更新后、依据',
level: 'L2',
predicate: `${D}_ledger_update_rows > 0 and ${D}_ledger_update_missing = 0`,
},
{
key: 'no-missed-update',
label: "每条「已完成」的执行回执至少有一条对应的台账更新(没有漏更)",
level: 'L2',
predicate: `${D}_ledger_update_rows >= ${D}_receipt_completion_status_done`,
},
{
key: 'update-has-evidence',
label: '每条台账更新都能在执行回执里找到对应事实依据,不是凭空推算',
level: 'L4',
},
{ key: 'values-reasonable', label: '更新后的数值(覆盖度、亲密度等)变化合理,没有异常跳变', level: 'L4' },
{ key: 'no-wrong-update', label: '更新内容准确对应回执事实,没有张冠李戴或误更别的客户/联系人', level: 'L4' },
/* L5 是责任关:那张卡本身就是这道关,卡面上不摆它(基座统一滤) */
{ key: 'owner-release', label: '台账更新入库放行:签下去即正式生效并回写主数据', level: 'L5' },
],
revisableAtRelease: [],
prohibitions: [
{ key: 'no-guess-contact', statement: '决策链节点对应哪位联系人不确定时不得臆断 —— 匹配不上就留空,那就是缺口' },
{ key: 'no-skip-evidence', statement: '台账更新明细每条都要有回执依据,不得凭空推算写入主数据' },
{ key: 'no-external-write', statement: '放行前不得触达外部系统 —— 触达世界的动作集中在放行那一刻' },
],
authorization: {
discretion: [],
preauthorizedActions: [],
/*
* 基线四次(S2/S4通过/S5/T_release)+ 决策卡循环预留两轮(每轮驳回+重提交各算一次
* 人回复,两轮就是 +4)——**基座按 `used >= cap` 判耗尽**(实测栽过:6 次刚好等于
* 6 的上限,「裁决:pass」那次回复本身就是第 6 次,harnessCheck 在 land() 之前
* 就判耗尽,放行卡显示 9/9 达标、验收也点了通过,任务却终态 exhausted、草稿从没
* 合并进正式版)。留够余量,cap 必须严格大于「最后一次拍板」时的实际次数。
*/
humanCallBudget: 12,
},
resources: {
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
skills: COVERAGE_SKILLS.map((s) => s.id),
models: ['deepseek-v4-flash'],
roles: ['AM Lead'],
/* 月度关系维护不是 live-inbox 那种时间敏感的单;人环预算同上调宽到 12(见上注) */
budget: { steps: 80, tokens: 500_000, activeMinutes: 90, humanCalls: 12 },
},
},
/* ── 声明 ────────────────────────────────────────────────────── */
declarations: {
models: ['deepseek-v4-flash'],
tools: [{ name: 'crm-relationship-coverage.recompute-score' }, { name: 'crm-relationship-coverage.rank-key-persons' }],
ledger: [
/*
* ── 主数据追加列(kind:'master' 显式声明,纯 add-column,与 A1 现有声明并存)──
* 只声明本活动要用的新列;`customer_code` 只是回显/关联用途的重复声明,
* 平台已由 `customer.profile`/`customer.contact` 的全量结构持有主键。
*/
{
domain: 'customer',
object: 'profile',
kind: 'master',
label: '客户档案',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* 建档卡会写的那几列(name/industry/region)也要声明 —— **授权面是台账声明**:
* 说明书让录入卡落什么,声明里就得有什么(物理列平台基线本就有,声明是权责面)。
* 与 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: '区域' },
/*
* L-02 文档明列的字段,权威维护者是尚未建成的 T02(客户建档评估) —— 平时
* E04 不写它;唯一的例外是"新客户建立合作"这一刻,E04 代替不存在的 T02
* 履行建档职责(与 A1 quote-intake 的 S1_customer 完全同构),此时由数据
* 录入卡收人现场给的值。不带 tally——存文档给的中文枚举字面值,不再造一套
* ASCII 编码来回译(这一列从不参与 data-class-of 徽标染色,没有 ASCII 约束)。
*/
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'coverage_score', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系覆盖度' },
/* 结构:{nodes:[{node, btLevel, required, contactCode, covered}]} —— 见 case-model.ts 的 guide 注 */
{ name: 'decision_chain', type: 'json', primaryKey: false, masked: false, required: true, label: '决策链地图' },
/* 结构:{entries:[{date, type, contactCode, note}]},append-only(每次整个数组重写) */
{ name: 'interaction_timeline', type: 'json', primaryKey: false, masked: false, expect: true, label: '互动时间线' },
],
},
{
domain: 'customer',
object: 'contact',
kind: 'master',
label: '客户联系人',
fields: [
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户编码' },
/*
* `auto:true` **必须跟 A1 一致**(2026-08-20 修死):`autoKeys()`
* (`apps/api/src/app/routes/desk.ts`)按**当前任务自己所属活动**的声明判断
* 哪几列自增,不是按全仓合并后的物理表结构 —— E04 若只抄字段名不抄这个标记,
* E04 名下的任务发数据录入卡建新联系人时,`contact_code` 就悄悄不再自动生成
* (静默退化成留空,不报错)。
*/
{ name: 'contact_code', type: 'text', primaryKey: true, masked: false, auto: true, label: '联系人编码' },
/* 建档卡的联系人段会写的三列 —— 同上,声明即授权面;email 照平台口径标脱敏 */
{ name: 'name', type: 'text', primaryKey: false, masked: false, 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: '角色' },
{ name: 'decision_node', type: 'text', primaryKey: false, masked: false, expect: true, label: '决策链节点' },
{
name: 'influence',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '影响力评级',
},
{ name: 'relationship', type: 'integer', primaryKey: false, masked: false, required: true, label: '关系亲密度(1-5)' },
{ name: 'comm_pref', type: 'text', primaryKey: false, masked: false, expect: true, label: '沟通偏好' },
{ name: 'key_person', type: 'boolean', primaryKey: false, masked: false, required: true, label: '关键人标记' },
/* 我方追加的批注(入职/离职/调岗留痕),不标 expect —— 标了会让 _gaps 永远大于零 */
{ name: 'change_log', type: 'text', primaryKey: false, masked: false, label: '变动记录' },
],
},
/* ── E04 自有工作台账(kind:'record',caseKey:'round_ref') ── */
{
domain: D,
object: 'candidate',
kind: 'record',
caseKey: 'round_ref',
label: '候选覆盖客户清单',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'customer_code', type: 'text', primaryKey: true, masked: false, required: true, label: '客户编码' },
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, required: true, label: '客户名称' },
/* 本轮工作快照 —— 有 customer.profile.cust_type 就抄它,没有才按台账信号猜(见 guide) */
{ name: 'cust_type', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户类型' },
{ name: 'bt_level', type: 'text', primaryKey: false, masked: false, expect: true, label: 'BT 层级' },
{ name: 'status', type: 'text', primaryKey: false, masked: false, tally: ['pending', 'covered', 'excluded'], label: '状态' },
{ name: 'basis', type: 'text', primaryKey: false, masked: false, expect: true, label: '纳入依据' },
{ name: 'depth', type: 'text', primaryKey: false, masked: false, expect: true, label: '覆盖深度' },
{ name: 'origin', type: 'text', primaryKey: false, masked: false, label: '来源(ai/human-added)' },
],
},
{
domain: D,
object: 'plan',
kind: 'record',
caseKey: 'round_ref',
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: '客户编码' },
{ 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: '建议动作' },
{
name: 'priority',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['high', 'medium', 'low'],
label: '优先级',
},
{ name: 'rationale', type: 'text', primaryKey: false, masked: false, required: true, label: '理由' },
{ name: 'influence', type: 'text', primaryKey: false, masked: false, required: true, label: '影响力(写计划那一刻的快照)' },
{ name: 'relationship_gap', type: 'integer', primaryKey: false, masked: false, required: true, label: '亲密度缺口' },
{ name: 'rank_score', type: 'number', primaryKey: false, masked: false, required: true, label: '排序分(模版不排序,须已按此列降序写入)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, required: true, label: '建议动作时间点' },
/*
* 没有 status 列(2.0.0 撤):tally 里的 approved 从没人写过 —— 「计划过没过审」
* 由 plan_approved_total 一个数说了算,同一件事实两处各存一份就会分叉。
*/
],
},
{
/*
* 与 `plan` 分表:回执列若挤进 `plan`,`required` 是对象级全局声明 ——
* S3/S4 阶段的 `_missing` 会把还没执行的回执列也算成「缺」,doneWhen/L2 判据
* 永远打不开。S3 那一步顺手把 action/target_date 回填进来,供 S5 工单只读展示
* (单张表内混排只读+可编辑列,复用工作区渲染器 mine:域.对象 单地址绑定的既有能力)。
*/
domain: D,
object: 'receipt',
kind: 'record',
caseKey: 'round_ref',
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, expect: true, label: '客户编码' },
/* 目标件的「对象」列长「AvtoKom · 产品 VP」—— 客户名与人名都要在,S3 从计划抄 */
{ name: 'customer_name', type: 'text', primaryKey: false, masked: false, label: '客户名称(承继自计划,只读展示)' },
{ name: 'contact_name', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人姓名' },
{ name: 'action', type: 'text', primaryKey: false, masked: false, label: '计划动作(承继自计划,只读展示)' },
{ name: 'target_date', type: 'date', primaryKey: false, masked: false, label: '建议时间点(承继自计划,只读展示)' },
{
name: 'completion_status',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['done', 'partial', 'skipped'],
label: '完成状态',
},
{ name: 'result_summary', type: 'text', primaryKey: false, masked: false, required: true, label: '结果摘要' },
{ name: 'customer_feedback', type: 'text', primaryKey: false, masked: false, expect: true, label: '客户反馈' },
{
name: 'key_person_change',
type: 'text',
primaryKey: false,
masked: false,
required: true,
tally: ['changed', 'unchanged'],
label: '关键人变动',
},
],
},
{
domain: D,
object: 'ledger_update',
kind: 'record',
caseKey: 'round_ref',
label: '台账更新明细',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
{ name: 'update_seq', type: 'integer', primaryKey: true, masked: false, label: '本轮更新序号' },
{ name: 'ledger_label', type: 'text', primaryKey: false, masked: false, required: true, label: '台账(人读名)' },
{ name: 'ledger_address', type: 'text', primaryKey: false, masked: false, required: true, label: '台账地址(机器地址)' },
{ name: 'customer_code', type: 'text', primaryKey: false, masked: false, required: true, label: '客户编码' },
{ name: 'contact_code', type: 'text', primaryKey: false, masked: false, expect: true, label: '联系人编码(空=客户级字段)' },
{ 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: '依据(引用具体回执事实)' },
],
},
{
domain: D,
object: 'verdict',
kind: 'record',
caseKey: 'round_ref',
label: '本轮覆盖结论',
fields: [
{ name: 'round_ref', type: 'text', primaryKey: true, masked: false, label: '覆盖轮次编号' },
/*
* 列名 `trigger_label` 不叫 `trigger`(2.0.0 改):任务视图把草稿列摊平到顶层、
* 与起单入参**同名同位** —— 从前这一列就叫 trigger,S1 写下中文标签之前,
* 视图上 `data-slot="trigger"` 摆的是入参英文码(monthly/event),
* 「报告上不摆系统内英文名」在首帧上破了功。改名后绑定读 trigger_label,
* 写下之前那一格真空着(data-blank),空着是诚实的。
*/
{ name: 'trigger_label', type: 'text', primaryKey: false, masked: false, label: '触发方式(人读标签,非入参英文码)' },
{ name: 'event_type', type: 'text', primaryKey: false, masked: false, label: '事件类型(入参原样抄的人读标签,月度例行留空)' },
{ name: 'note', type: 'text', primaryKey: false, masked: false, label: '本轮说明' },
{ name: 'scope_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '覆盖范围已确认' },
{ name: 'plan_approved_total', type: 'integer', primaryKey: false, masked: false, label: '经营计划已审批通过' },
/*
* 没有 receipt_confirmed_total(2.0.2 退役,两轮活单熔断实测):模型自写的
* 「人环走完」旗让它抢跑 —— S5 的门改读基座数(receipt _rows/_missing,
* 回执四列全是人在件上填出来的),被叫的那一方无账可记。
*/
{ name: 'ledger_update_confirmed_total', type: 'integer', primaryKey: false, masked: false, label: '台账更新已收口' },
{ name: 'covered_customer_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖客户数' },
{ name: 'key_person_covered_total', type: 'integer', primaryKey: false, masked: false, label: '本轮覆盖关键人数' },
{ name: 'summary', type: 'text', primaryKey: false, masked: false, label: '本轮覆盖结论' },
],
},
],
listeners: [],
harness: {
memoryWindow: 24,
/* 月度关系维护不是 live-inbox 那种紧迫的单,给够时间让人执行拜访这类线下动作 */
stallAfter: 'PT4H',
nudge: [{ after: 'PT2H', action: 'nudge' }],
retry: { times: 2, backoff: 'PT30S' },
},
},
/* ── 资产初始版 ──────────────────────────────────────────────── */
assets: {
businessModel: {
cmmnXml: CMMN_XML,
narrative: NARRATIVE,
dmn: EMPTY_DMN,
steps: COVERAGE_STEPS,
phases: COVERAGE_PHASES,
},
skills: COVERAGE_SKILLS,
/*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选客户清单,可编辑;
* · `coverage-plan` —— S3/S4 摆,经营计划,只读(业务说明明文"人只做审批,不能修改结论");
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/时间点)+ 可编辑(回执四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,只读,收口后即归档件。
*/
viewTemplates: [
{ name: 'coverage-scope', html: COVERAGE_SCOPE },
{ name: 'coverage-plan', html: COVERAGE_PLAN },
{ name: 'coverage-receipt', html: COVERAGE_RECEIPT },
{ name: 'coverage-ledger-update', html: COVERAGE_LEDGER_UPDATE },
],
cardSchemas: {},
distillHooks: [
{
on: 'terminal',
prompt: '归纳本轮关系覆盖:候选客户识别依据、计划审批是否被驳回过(为什么)、执行回执里客户反馈最集中的诉求、覆盖度变化幅度。',
},
],
},
/*
* ── 样例集 —— 五条形状真不同(常备案例即验收案例)─────────────
*
* **第一条必须是满载的那条**(2026-08-23 用户:「询价受理起单时左边都有预填好的入参,
* E04 也要」):测试台落到一个活动上就自动铺 `samples[0]`(activity-run.tsx 的
* useEffect),所以第一条就是人打开这一屏看见的那份入参。从前第一条是月度例行、
* 只有 `{trigger:'monthly'}` 一个键 —— 表单上除了那个下拉全是空的,看着像没预填。
* 现在第一条是**点名客户的事件驱动**:四个字段全有值,起单钮按下去就能跑。
*
* **样例名一律 ≤14 字**:测试台的样例钮超过 14 字就退成「样例 N」(activity-run.tsx),
* 那等于没名字 —— 人得逐个点开才知道哪条是哪条。
*/
samples: [
{
name: '重大商机 · 点名客户',
input: {
trigger: 'event',
eventType: 'major_deal',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)启动新项目 MD14.2,商机金额较大,AM Lead 要求本轮优先覆盖其决策链',
},
expect: { ports: ['coverage-map'] },
},
{
/* 月度例行的本分就是不点名(客户格留空 = 由 AI 按台账规则筛)—— 但背景说明要给,不然表单半空 */
name: '月度例行 · 全库筛',
input: {
trigger: 'monthly',
note: '八月例行覆盖轮次:优先看覆盖度陈旧、决策链有空白节点的重点客户',
},
expect: { ports: ['coverage-map'] },
},
{
name: '关键人离职调岗',
input: {
trigger: 'event',
eventType: 'key_person_change',
customers: ['CU-0087'],
note: '客户 CU-0087 采购负责人 Chen Gang 已离职,需要重新识别关键人并更新决策链',
},
expect: { ports: ['coverage-map'] },
},
{
name: '新客户建立合作',
input: {
trigger: 'event',
eventType: 'new_customer',
customers: [
{
name: '青岛新星电子有限公司',
custType: '模组厂',
industry: '车规电子',
region: '山东',
profileTags: '价格敏感 · 首次合作',
contact: { name: '王芳', title: '采购经理', email: 'wang.fang@example.com' },
},
],
note: 'AM Lead 上周展会新结识的客户,想尽快建档并启动关系覆盖',
},
expect: { ports: ['coverage-map'] },
},
{
name: '攻坚项目 T04 立项',
input: {
trigger: 'event',
eventType: 't04_project',
customers: ['CU-0142'],
note: '客户 CU-0142(Itelma)的 MD14.2 项目已升级为 T04 攻坚项目,需要加强决策链覆盖、确保关键节点无缺口',
},
expect: { ports: ['coverage-map'] },
},
],
entry: { file: 'index.js', checksum: 'dev-entry-crm-relationship-coverage' },
};
@@ -0,0 +1,79 @@
/**
* E04 技能 —— 关系覆盖经营的方法经验。载方法,不载权力。
*
* 与剧本的分工:剧本写「这一步做什么、写哪几列」,技能写「为什么这么判、
* 拿不准时按哪条走」。剧本改了口径,技能里同一句要跟着改(规范 §5.9)。
*/
import { skillIdSchema } from '@contracts';
import type { Skill } from '@contracts';
export const COVERAGE_SKILLS: Skill[] = [
{
id: skillIdSchema.parse('crm-coverage.gap-analysis'),
label: '读覆盖现状、找决策链空白节点',
applicability: '识别候选客户之后、拟经营计划之前,要判断这一轮该覆盖谁。',
method: [
'决策链地图(`customer.profile.decision_chain`)按 BT 层级列节点:',
'· BT-01 采购/供应链层、BT-02 技术层(研发/质量)、BT-03 高层(产品总监/VP)。',
'一个节点算「已覆盖」,要同时满足三条:该节点上有联系人对应、那个人标了',
'`key_person=true`、亲密度 `relationship >= 3`。三条少一条都算空白 ——',
'认得这个人不等于覆盖了这个人,亲密度不够的关键人一样是缺口。',
'',
'调 `crm-relationship-coverage.recompute-score` 拿 `gapNodes`,别自己数:',
'节点覆盖是逐条核对的活,人算容易漏,数得出来的事一律让工具数。',
'**批量调**(入参 `customers` 数组,一次多家客户):工具调用后同拍其余动作作废,',
'一家客户调一次就要多截断一拍,而结果是同一份数据算出来的,批量传入不影响精度。',
'',
'`gapNodes` 全员缺口(一个节点都没覆盖到)是正常结果,不是异常 —— 不要回头重调工具',
'确认;缺口本身就是下一步经营计划要处理的东西。',
'',
'缺口分析只认台账现状,不猜将来:客户明说要覆盖某个人,先看 `customer.contact`',
'里有没有这个人的记录,没有的话缺口分析给不出他,只能在计划里写「需先建档」。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['决策链地图', 'BT 层级'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.plan-drafting'),
label: '按影响力 × 亲密度缺口排经营计划',
applicability: '缺口找齐之后,要给每个关键人排优先级、定动作时。',
method: [
'排序不是拍脑袋,是尺:调 `crm-relationship-coverage.rank-key-persons` 批量算',
'(一次多条,不要一条关键人调一次 —— 拿回来的排序结果本就是同一份数据算出来的)。',
'`influence` 权重高的、`relationship` 缺口大的排前面 —— 一个天天见面的低影响力的人,',
'排序上必然让位给一个见不到面的高影响力的人,这是尺给的结论,不是你临场判断的。',
'',
'建议动作要**具体到能执行**:「拜访」不够,「下月中拜访技术总监,带 XX 认证报告」',
'才算数;每条动作配一个 `target_date`,没有时间点的建议是空话,验收判据③会核这个。',
'',
'理由(`rationale`)要说清「为什么是这个人、这个动作」——',
'引影响力评级与亲密度差值,不要只写「重要客户,建议拜访」这类空话,',
'人审计划时判的就是这句话站不站得住。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['影响力 × 亲密度缺口排序'],
version: '1.0.0',
},
{
id: skillIdSchema.parse('crm-coverage.ledger-update'),
label: '收口写台账、每条更新配依据',
applicability: '执行回执收齐之后,要把结果写回 L-02/L-08 主数据时。',
method: [
'每一条更新都要能在回执里找到对应的事实 —— `evidence_ref` 不是可选的装饰,',
'是验收判据①的唯一抓手:写「回执 contact_code=CU-0142-01:已完成拜访,',
'亲密度由3提升至4」这种能溯源的话,不写「综合判断」这类查无实据的话。',
'',
'覆盖度 `coverage_score` 收口时按执行后的最新数据重算一次(调',
'`crm-relationship-coverage.recompute-score`),不要凭回执的完成条数估一个大概值 ——',
'分数要能重算出同一个数,重算不出来的分数没人信得过。',
'',
'「无变动」也要写清楚:回执里 `key_person_change` 是 `unchanged`(人在卡上选的',
'「无变动」)的联系人,台账更新明细里不必为它单开一行,但不许因为没变动就漏掉',
'这条覆盖度的重算依据。',
].join('\n'),
crystallized: [],
knowledgeRefs: ['台账更新依据'],
version: '1.0.0',
},
];
@@ -0,0 +1,162 @@
/**
* E04 关系覆盖与关键人经营 · 视觉底(**E04 自己的 PRD 目标件,不是借来的家族版面**)。
*
* 上一版这里写着「E04 没有自己的 PRD 目标件」——那是假的:
* `docs/SS01-客户与市场/e04-html/` 下的四份 HTML(执行工单 1/2、验收工单 1/2)正是
* E04 自己的目标件,颜色变量、卡头渐变、判据框、原生 select/textarea 全部逐字量自那四份文件,
* 不是 A1/A2 那一支「浮起卡片 + 渐变徽章 + 头像签名」的家族版面。
*
* 两处**有意**偏离目标件,原因写在这里(别处再改记得回头看):
* 1. 目标件的必填只在表头打一个「*」;本仓有个更早、已验证过的平台级三档占位系统
* (必填红 / 建议填橘 / 选填灰,2026-08-02 定,`docs/HTML报告生成规范.md` 收录),
* 比「只在表头提一句」更能在空格子上直接指路。两者都留着:表头照抄目标件的「*」,
* 空格子再叠一层三档胶囊——不冲突,目标件的例子本来就没有空格子可比对。
* 2. 候选清单「状态」列:目标件那一列是「正式/试用」(客户主数据的状态),
* 本活动的 `candidate.status` 是另一个真实字段(`pending/covered/excluded`,
* 候选行本轮的覆盖生命周期,`declarations.ledger` 里 tally 着),两者恰好同名不同义。
* 留的是本活动这个——它是真在用的数据,目标件那份是另一件事。
*
* 版面照目标件分两层:这份文件是**底**(配色变量、卡壳、头/尾、判据框、说明框、表格基础排版),
* `report.ts` 里的 `BIND_CSS` 是**面**(哪个值决定哪种颜色/文案——三档占位、缺项红带、
* 状态徽、编辑闸开合、行增删这些「数据驱动的开关」,判断在样式表,取值在模版,两处不许混)。
*/
export const PRD_CSS = `
.proto-root {
--ink:#1f2329; --ink-2:#41464e; --ink-3:#646a73; --ink-4:#8f959e;
--line:#dee0e3; --line-soft:#e8eaed; --soft:#f8f9fb;
--brand:#3370ff; --brand-deep:#245bdb; --brand-soft:#e1eaff; --brand-wash:#f0f4ff;
--human:#a9711e; --human-soft:#f6ecd8; --human-line:#e6d3a8;
--ledger:#b23a2e; --ledger-soft:#f7e4e0; --ledger-line:#ecc5bd;
--ok:#278717; --ok-soft:#e8f8e4;
--danger:#d83931; --danger-soft:#feeae9;
--warn-accent:#a9711e; --warn-soft:#f6ecd8;
--mono: "SF Mono", Menlo, Consolas, monospace;
--t-fast: .16s;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
font-size: 14px;
line-height: 1.7;
}
.proto-root, .proto-root * { box-sizing: border-box; }
/* ==================== 卡壳(嵌进工作区,不自带浮起——外层已经是一张卡)==================== */
.proto-root .jc-report { background: #fff; overflow: hidden; }
.proto-root .jc-rp-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 2px solid var(--ink); }
.proto-root .jc-report.tone-human .jc-rp-head { background: linear-gradient(120deg, #fffaf1 0%, #fff 55%, #fbf6ea 100%); }
.proto-root .jc-report.tone-ledger .jc-rp-head { background: linear-gradient(120deg, #fdf3f1 0%, #fff 55%, #fbf2f0 100%); }
.proto-root .jc-rp-badge {
flex: none; width: 44px; height: 44px; border-radius: 10px;
display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 13px;
}
.proto-root .jc-report.tone-human .jc-rp-badge { background: var(--human); box-shadow: 0 6px 16px rgba(169,113,30,.25); }
.proto-root .jc-report.tone-ledger .jc-rp-badge { background: var(--ledger); box-shadow: 0 6px 16px rgba(178,58,46,.25); }
.proto-root .jc-rp-tit { flex: 1; min-width: 0; }
.proto-root .jc-rp-tit em { font-style: normal; font-size: 11px; letter-spacing: .08em; color: var(--ink-4); font-weight: 700; }
.proto-root .jc-rp-tit h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; line-height: 1.3; }
.proto-root .jc-rp-sub { font-size: 12px; color: var(--ink-3); margin-top: 3px; }
.proto-root .jc-rp-side { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
/* 目标件 side 第二行:步骤(静态文案),同目标件 .step 的字号字色 */
.proto-root .jc-rp-step { font-size: 12px; color: var(--ink-3); }
/* 关联任务号:全长 UUID 如实给,排版上截尾 —— 截断是排版,不是数据 */
.proto-root .jc-rp-taskid {
display: inline-block; max-width: 150px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: 11px;
}
.proto-root .jc-rp-tag { font-size: 11px; font-weight: 800; padding: 2px 10px; border-radius: 999px; border: 1px solid; }
.proto-root .jc-report.tone-human .jc-rp-tag { color: var(--human); background: var(--human-soft); border-color: var(--human-line); }
.proto-root .jc-report.tone-ledger .jc-rp-tag { color: var(--ledger); background: var(--ledger-soft); border-color: var(--ledger-line); }
.proto-root .jc-rp-wait { font-size: 12px; color: var(--ink-3); }
.proto-root .jc-rp-wait.warn { color: var(--danger); font-weight: 700; }
.proto-root .jc-rp-bd { padding: 20px 22px; }
/* 【做什么】/【交回什么】这类分区小标——括号label + 破折号说明,同一行 */
.proto-root .jc-rp-part { font-size: 11px; font-weight: 800; letter-spacing: .12em; color: var(--ink-4); margin: 20px 0 8px; }
.proto-root .jc-rp-part:first-child { margin-top: 0; }
.proto-root .jc-rp-part b { color: var(--ink-3); }
/* AI 已完成 / 需要你 —— 说明框,左侧色条按 tone 走 */
.proto-root .jc-derive { border: 1px solid var(--line); border-left: 3px solid; border-radius: 8px; background: var(--soft); padding: 14px 16px; margin-bottom: 16px; }
.proto-root .jc-report.tone-human .jc-derive { border-left-color: var(--human); }
.proto-root .jc-report.tone-ledger .jc-derive { border-left-color: var(--ledger); }
.proto-root .jc-derive p { margin: 0; font-size: 13px; color: var(--ink-2); }
.proto-root .jc-derive p + p { margin-top: 6px; }
.proto-root .jc-derive b { color: var(--ink); }
.proto-root .jc-derive .done { color: var(--ok); font-weight: 700; }
.proto-root .jc-report.tone-human .jc-derive .need { color: var(--human); font-weight: 700; }
.proto-root .jc-report.tone-ledger .jc-derive .need { color: var(--ledger); font-weight: 700; }
/* 验收判据框——目标件里两份验收工单同一色(赭红),不随 tone 变 */
.proto-root .jc-criteria { border: 1px solid var(--ledger-line); border-radius: 10px; background: var(--ledger-soft); padding: 13px 16px; margin-bottom: 18px; }
.proto-root .jc-criteria-t { font-size: 11px; font-weight: 800; letter-spacing: .08em; color: var(--ledger); margin-bottom: 7px; }
.proto-root .jc-criteria ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.proto-root .jc-criteria li { margin: 4px 0; }
.proto-root .jc-criteria b { color: var(--ink); }
/* 表格基础排版(交互态的显隐开关在 BIND_CSS) */
/*
* **表宽超出就得能横着滑**(2026-08-23 用户:「表格已经显示不全了,不能左右滑动」)。
* 从前这里是 overflow: hidden —— 那是为了让圆角裁住表头,可它同时把超出的列
* **直接裁掉且滑不动**:列一多(或某列为了放下下拉框加宽)就看不全,人还没有任何办法。
* quote-* 各包一直是 overflow-x: auto,这两个新包抄版式时把这一条抄丢了。
* (注释里不写反引号 —— 整段 CSS 是模版字符串,一个反引号就把它截断,本轮踩第三次。)
* 竖向仍 hidden:件是整篇铺开跟着外层滚的,表格自己不该再套一层竖滚动条。
*/
.proto-root .jc-tbl-wrap { border: 1px solid var(--line-soft); border-radius: 10px; overflow-x: auto; overflow-y: hidden; margin-bottom: 4px; }
.proto-root .jc-tbl-in { width: 100%; border-collapse: collapse; font-size: 13px; }
.proto-root .jc-tbl-in th { text-align: left; background: var(--soft); font-size: 11px; letter-spacing: .04em; font-weight: 800; color: var(--ink-3); padding: 9px 11px; border-bottom: 1px solid var(--line); }
.proto-root .jc-tbl-in th.req::after { content: " *"; color: var(--danger); font-size: 11px; }
.proto-root .jc-tbl-in th.sug::after { content: " 建议"; color: var(--warn-accent); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in th.opt::after { content: " 选填"; color: var(--ink-4); font-size: 10px; font-weight: 600; }
.proto-root .jc-tbl-in td { padding: 9px 11px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
.proto-root .jc-tbl-in tr:last-child td { border-bottom: 0; }
.proto-root .jc-tbl-in td.jc-rn { width: 38px; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--ink-4); text-align: center; position: relative; }
.proto-root .jc-vcell { display: block; font-size: 12.5px; color: var(--ink); line-height: 1.5; overflow-wrap: anywhere; }
.proto-root .jc-vcell.mono { font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); }
.proto-root .jc-vcell.cust { font-weight: 700; }
.proto-root .jc-vcell.dim { color: var(--ink-4); text-decoration: line-through; }
.proto-root .jc-diffnew { font-weight: 800; color: var(--ok); }
.proto-root .jc-cell, .proto-root select.jc-cell, .proto-root textarea.jc-cell {
width: 100%; min-width: 60px; border: 1px solid var(--line); border-radius: 6px;
padding: 6px 8px; font: inherit; font-size: 12.5px; color: var(--ink); background: #fff;
}
/* 多行文本照目标件:最小两行高,竖向可拉 */
.proto-root textarea.jc-cell { min-height: 40px; resize: vertical; }
.proto-root .jc-cell.mono { font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-cell:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-wash); outline: none; }
/* 格内次行小字(依据 / 编码)—— 主行之下,不占独立列 */
.proto-root .jc-sub { display: block; font-size: 11px; color: var(--ink-4); margin-top: 2px; line-height: 1.55; }
.proto-root .jc-sub i { font-style: normal; }
/* 取值位(i)全空的次行整行藏掉;直填文本的次行(如对象格的编码)不归这条管 */
.proto-root .jc-sub:has(i):not(:has(i:not(:empty))) { display: none; }
.proto-root .jc-rowdel {
width: 22px; height: 22px; margin: 0 auto; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px; padding: 0;
font: inherit; font-size: 14px; line-height: 1; color: var(--ink-3); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-rowdel:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-soft); }
.proto-root .jc-addrow em { font-style: normal; font-size: 11.5px; color: var(--ink-4); }
.proto-root .jc-addrow button {
cursor: pointer; border: 1px solid var(--brand-soft); border-radius: 7px; padding: 5px 12px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--brand-deep); background: var(--brand-wash);
}
.proto-root .jc-addrow button:hover { background: var(--brand-soft); }
/* 本工单只承载内容,提交/审批请在对话区——目标件四份文件都有这一条 */
.proto-root .jc-flow { margin-top: 16px; padding: 9px 12px; border-radius: 8px; background: var(--soft); border: 1px dashed var(--line); font-size: 12px; color: var(--ink-3); }
.proto-root .jc-flow b { color: var(--ink); }
.proto-root .jc-rp-foot { padding: 13px 22px; border-top: 1px solid var(--line-soft); font-size: 11.5px; color: var(--ink-4); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
.proto-root .jc-rp-foot span:last-child { font-family: var(--mono); }
`;
@@ -0,0 +1,551 @@
/**
* E04 关系覆盖与关键人经营的 HTML 报告模版 —— **活动自己持有的资产**,随包版本冻结。
*
* 四个局面各一张:
* · `coverage-scope` —— S1/S2 摆,候选覆盖客户清单,可编辑(增删改);
* · `coverage-plan` —— S3/S4 摆,关键人经营计划,**只读**(业务说明明文
* "人只做审批,不能修改结论"——通过/驳回的按钮在对话区的决策卡上,不在这份件里);
* · `coverage-receipt` —— S5 摆,执行回执,混排只读(计划动作/对象承继自计划)+
* 可编辑(完成状态/结果摘要/客户反馈/关键人变动四列);
* · `coverage-ledger-update` —— S6/T_release 摆,台账更新明细,**只读**,放行后即归档件。
*
* 版面语言逐字量自 E04 自己的目标件(`docs/SS01-客户与市场/e04-html/` 四份 HTML,
* `prd-style.ts` 头注写了两处有意偏离的地方)。
*
* 取值三纪律:顶层只取汇总列与单值列;逐条走 `mine:crm_coverage.<object>`;
* 列名逐字照 `declarations.ledger` 抄。**模版不算不判** —— 缺必填红带、状态徽、
* 排序位次全部由基座算好的数经 `data-class-of` 投影,判断留在样式表。
*/
import { PRD_CSS } from './prd-style.ts';
const D = 'crm_coverage';
/* ==================== 绑定期补的样式(数据驱动的开关,判断都在这一层)==================== */
const BIND_CSS = `
/* 空占位三级(全局口径):必填红 / 建议填橘 / 选填灰 —— 值真的空才轮到 */
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after,
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after {
display: inline-flex; align-items: center; font-style: normal; font-size: 10px; font-weight: 700;
line-height: 1; padding: 3px 7px; border-radius: 5px; border: 1px dashed;
}
.proto-root .jc-tbl-in td.req > .jc-vcell:empty::after { content: "必填"; color: var(--danger); background: var(--danger-soft); border-color: #f3b6b3; }
.proto-root .jc-tbl-in td.sug > .jc-vcell:empty::after { content: "建议填"; color: var(--warn-accent); background: var(--warn-soft); border-color: #f0cf9a; }
.proto-root .jc-tbl-in td.opt > .jc-vcell:empty::after { content: "选填"; color: var(--ink-4); background: var(--soft); border-color: var(--line); }
/* 归档件 / 只读件里空格子就是「没有」,不再摆「你还能填」的胶囊 */
.proto-root .jc-tbl-in td.dimempty > .jc-vcell:empty::after { content: "—"; color: var(--ink-4); border: 0; background: none; padding: 0; }
/* 缺必填的整行浅红 + 行首红条 —— 由 :has() 认 */
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td { background: #fffbfb; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty):hover td { background: #fff5f5; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn { color: var(--danger); font-weight: 800; }
.proto-root .jc-tbl-in tbody tr:has(td.req > .jc-vcell:empty) td.jc-rn::after {
content: ""; position: absolute; left: 0; top: 4px; bottom: 4px; width: 3px; border-radius: 0 3px 3px 0; background: var(--danger);
}
/* 序号由 CSS 计数器排 */
.proto-root .jc-tbl-in tbody { counter-reset: rn; }
.proto-root .jc-tbl-in td.jc-rn::before { counter-increment: rn; content: counter(rn); }
/*
* 就地改:可改格子渲了只读与输入两份,默认锁着,表上加 on 才换孪生件。
*
* **只换真有孪生件的那一份**(2026-08-21 用户送来实测截图查出来的坑):从前认
* 「表上有 on 就把这张表里所有 .jc-vcell 全藏起来」,而 coverage-receipt 混排
* 只读列(计划动作/对象里的联系人姓名——承继自计划,声明明写"改不了")与可改列
* (完成状态等),只读列压根没有 .jc-cell 孪生件可换 —— 一开编辑,那格字直接消失,
* 要退出编辑才重新看得见。**只藏「后面紧跟着一个孪生件」的那一份**:
* 没有孪生件的 .jc-vcell 不属于这道开关,编辑态里也照旧显示。
*/
.proto-root .jc-cell { display: none; }
.proto-root .jc-tbl-in.on td > .jc-vcell:has(+ .jc-cell) { display: none; }
.proto-root .jc-tbl-in.on td > .jc-cell { display: block; }
/* 编辑态下必填空格子:红框 + 整行浅红(由 :placeholder-shown / :invalid 认) */
.proto-root .jc-tbl-in.on td.req > select.jc-cell:invalid,
.proto-root .jc-tbl-in.on td.req > .jc-cell:placeholder-shown { border-color: #f3b6b3; background: #fffafa; }
.proto-root .jc-tbl-in.on tbody tr:has(td.req > .jc-cell:placeholder-shown),
.proto-root .jc-tbl-in.on tbody tr:has(td.req > select.jc-cell:invalid) { background: #fffbfb; }
/* 铺满工作区,卡壳不再自带外边框(外层容器已经是一张卡) */
.jc-stage { max-width: none; padding: 0 0 4px; }
/* 那道闸 —— 默认「已锁定 + 编辑」,开了变成「取消 / 保存」;只在可改屏现身 */
.proto-root .jc-editgate { display: none; }
html.editable .proto-root .jc-editgate {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: 14px 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--soft); border: 1px solid var(--line-soft);
font-size: 12px; color: var(--ink-3);
}
.proto-root .jc-editgate-t { flex: 1 1 auto; min-width: 0; }
.proto-root .jc-editgate-t b { color: var(--ink-2); font-weight: 700; }
.proto-root .jc-egbtn {
flex: none; display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
border: 1px solid var(--line); border-radius: 7px; padding: 6px 13px;
font: inherit; font-size: 12px; font-weight: 700; color: var(--ink-2); background: #fff;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
}
.proto-root .jc-egbtn:hover { border-color: var(--brand); color: var(--brand); }
.proto-root .jc-egbtn.save { color: #fff; background: var(--human); border-color: var(--human); }
.proto-root .jc-egbtn.save:hover { filter: brightness(1.06); color: #fff; }
.proto-root .jc-editgate .jc-egbtn.ghost, .proto-root .jc-editgate .jc-egbtn.save { display: none; }
.proto-root .jc-editgate.on { background: var(--human-soft); border-color: var(--human-line); color: #7c4a12; }
.proto-root .jc-editgate.on .jc-egbtn.open { display: none; }
.proto-root .jc-editgate.on .jc-egbtn.ghost, .proto-root .jc-editgate.on .jc-egbtn.save { display: inline-flex; }
.proto-root .jc-editgate.on .jc-editgate-t { visibility: hidden; }
/* 缺必填红带 —— 只在可改屏出,一格不缺自动收(v-0 那一档 display:none) */
.proto-root .jc-gapbar { display: none; }
html.editable .proto-root .jc-gapbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: #fff5f5; border: 1px solid #f3b6b3; color: var(--danger);
font-size: 12px; line-height: 1.65;
}
.proto-root .jc-gapbar b { font-weight: 800; }
.proto-root .jc-gapbar span { color: #9a3b36; }
html.editable .proto-root .jc-gapbar.v-0 { display: none; }
/* 那条「怎么改」的提示只在开了闸之后说 */
.proto-root .jc-editbar { display: none; }
.proto-root .jc-editgate.on + .jc-editbar {
display: flex; align-items: flex-start; gap: 8px;
margin: 0 0 10px; padding: 9px 12px; border-radius: 9px;
background: var(--brand-wash); border: 1px dashed var(--brand-soft); color: var(--ink-3);
font-size: 12px; line-height: 1.6;
}
.proto-root .jc-editbar b { color: var(--ink-2); }
/* 增删行 */
.proto-root .jc-rowdel { display: none; }
.proto-root .jc-tbl-in.on td.jc-rn::before { content: none; }
.proto-root .jc-tbl-in.on .jc-rowdel { display: block; }
.proto-root .jc-tbl-in.on tr.jc-row-gone td { background: #fff6f6; opacity: .55; }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-cell { text-decoration: line-through; color: var(--ink-3); }
.proto-root .jc-tbl-in.on tr.jc-row-gone .jc-rowdel { border-color: var(--danger); color: var(--danger); background: #fff; }
.proto-root .jc-tbl-in.on tr.jc-row-new td { background: #f2fbf7; }
.proto-root .jc-addrow { display: none; }
html.editable .proto-root .jc-tbl-wrap:has(table.on) + .jc-addrow {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin: -2px 0 12px; padding: 7px 12px; border-radius: 9px;
background: var(--soft); border: 1px dashed var(--line);
}
/*
* 统一的枚举标签(候选状态 / 优先级 / 完成状态 / 关键人变动 共用同一套机制):
* 值是机器码,人读名与颜色一律由 data-class-of 投影出的 .v-xxx 决定,不写死在取值那一侧。
* 三组值域(pending/covered/excluded、high/medium/low、done/partial/skipped、是/无变动)各自的
* .v-* 类名不会互相碰撞,同一个 .jc-tag 基座够用,不必分三套徽章系统。
*/
.proto-root .jc-tag { font-weight: 800; }
.proto-root .jc-tag::after { content: "待定"; color: var(--ink-4); font-weight: 600; }
.proto-root .jc-tag.v-pending::after { content: "待覆盖"; }
.proto-root .jc-tag.v-pending { color: var(--warn-accent); }
.proto-root .jc-tag.v-covered::after { content: "已覆盖"; }
.proto-root .jc-tag.v-covered { color: var(--ok); }
.proto-root .jc-tag.v-excluded::after { content: "已排除"; }
.proto-root .jc-tag.v-excluded { color: var(--ink-3); }
.proto-root .jc-tag.v-high::after { content: "高"; }
.proto-root .jc-tag.v-high { color: var(--danger); font-family: var(--mono); }
.proto-root .jc-tag.v-medium::after { content: "中"; }
.proto-root .jc-tag.v-medium { color: var(--ink-2); font-family: var(--mono); }
.proto-root .jc-tag.v-low::after { content: "低"; }
.proto-root .jc-tag.v-low { color: var(--ink-4); font-family: var(--mono); }
.proto-root .jc-tag.v-done::after { content: "完成"; }
.proto-root .jc-tag.v-done { color: var(--ok); }
.proto-root .jc-tag.v-partial::after { content: "部分完成"; }
.proto-root .jc-tag.v-partial { color: var(--warn-accent); }
.proto-root .jc-tag.v-skipped::after { content: "未执行"; }
.proto-root .jc-tag.v-skipped { color: var(--ink-3); }
/*
* **值域不能用中文**(2026-08-21 用户送来实测截图查出来的坑):classToken()
* 只放行 ASCII 字母数字与横杠(见 view-render.ts 的注释——属性位插值唯一开放
* 的白名单,宁可没有颜色也不给一个错的钩子),中文字符整段被消毒成空字符串,
* data-class-of="key_person_change" 因此永远长不出 v-是/v-无变动 这两个
* class —— 徽章只会落到默认的「待定」,人怎么选、选了多少次都一样,像是保存没生效。
* 机器值改成 ASCII(changed/unchanged),中文只留在这里的 ::after 与
* option 文字里(人看到的字一个没少)。
*/
.proto-root .jc-tag.v-changed::after { content: "是"; }
.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); }
/*
* **覆盖深度同一坑的另一半**(2026-08-21 用户:「编辑的下拉框是中文,显示是英文」)——
* depth 本身不含中文(说明书写死 ASCII deep/standard/light,不撞 classToken 那道墙),
* 但只读那一份从前是 .jc-vcell 直接回显原值,人看到的是英文单词,跟下拉选项(深度/标准/
* 轻度)对不上。换成同一套 data-class-of + .jc-tag 机制,人读的字上下一致。
*/
/*
* **BT 层级同一坑的第三半**(2026-08-23 用户:「BT 层级下拉框显示不全,
* 都看不到具体选了哪个」)。两处一起错:
* · 列宽只有 80px,而选项文字是「BT-01 · 采购/供应链」—— 下拉被挤扁,选了什么看不见
* (覆盖深度那列因为选项只有两个字,一直没暴露);
* · 只读那一份是 data-slot 直显,存的是 ASCII bt-02,人看到的就是 bt-02 ——
* 与编辑态下拉里写的「BT-02 · 技术」对不上。覆盖深度当初专门修过这个坑,
* BT 层级漏了。现在两列同一套:值仍是 ASCII(台账与 recompute-score 的
* btLevel 入参认它),人读的字由这里出。
*/
.proto-root .jc-tag.v-bt-01::after { content: "BT-01 · 采购/供应链"; }
.proto-root .jc-tag.v-bt-02::after { content: "BT-02 · 技术"; }
.proto-root .jc-tag.v-bt-03::after { content: "BT-03 · 高层"; }
.proto-root .jc-tag.v-bt-01, .proto-root .jc-tag.v-bt-02, .proto-root .jc-tag.v-bt-03 { color: var(--ink-2); font-family: var(--mono); font-size: 11.5px; }
.proto-root .jc-tag.v-deep::after { content: "深度"; }
.proto-root .jc-tag.v-standard::after { content: "标准"; }
.proto-root .jc-tag.v-light::after { content: "轻度"; }
/* 状态徽承继自引擎产物,不进编辑态的显隐开关——on 时也照旧摆着,当「现在是什么」的参照 */
.proto-root .jc-tbl-in.on td > .jc-tag { display: inline-flex; align-items: center; margin-top: 4px; }
/*
* 「本轮触发 / 本轮说明」这类**值还没写下就整行不摆**的说明行:
* 行里所有取值位(b/span/i)都空着 → 整行藏掉 —— 「本轮说明:」后面跟着一片空白
* 比不摆更糟(像是丢了数据)。值一落(S1 写 verdict),行自己现身。
*/
.proto-root .jc-note-line:not(:has(b:not(:empty), span:not(:empty), i:not(:empty))) { display: none; }
`;
/**
* 三层壳照目标件:少一层样式表全失效。
*
* 卡头/页脚的动态位照目标件 §6.2 补齐(2.0.0):收件人(case:owner)、
* **关联任务**(case:taskId,基座注入的任务号)、**步骤**(step,目标件里每份工单
* 写死的静态文案,如「步骤 ① 启动与覆盖范围确认」)、页脚**单号**(round_ref)。
* 目标件还有一格「最迟交回」—— 本仓没有工单期限这个事实,**不摆假数据**,那一格不做。
*/
const doc = (input: {
tone: 'human' | 'ledger';
badge: string;
tag: string;
kicker: string;
title: string;
titleRef: string;
/** 目标件 side 第二行:静态步骤文案(哪份工单盖哪几步是写清单时定死的) */
step: string;
state: string;
body: string;
foot: string;
}): string =>
`<style>${PRD_CSS}${BIND_CSS}</style>
<div class="proto-root"><div class="app-shell"><div class="jc-stage">
<article class="jc-report tone-${input.tone}">
<header class="jc-rp-head">
<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>
<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">
<span class="jc-rp-tag">${input.tag}</span>
<span class="jc-rp-step">${input.step}</span>
${input.state}
</div>
</header>
<div class="jc-rp-bd">${input.body}</div>
<footer class="jc-rp-foot">${input.foot}</footer>
</article>
</div></div></div>`;
const part = (label: string, aside = ''): string =>
`<p class="jc-rp-part"><b>【${label}】</b>${aside === '' ? '' : ` —— ${aside}`}</p>`;
const criteria = (title: string, items: readonly string[]): string =>
`<div class="jc-criteria"><div class="jc-criteria-t">${title}</div><ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul></div>`;
const flow = (text: string): string => `<div class="jc-flow">${text}</div>`;
/** 页脚右侧照目标件长「工单类型:执行 · <单号>」—— 目标件的 card.id 没有真数据源,摆本轮案号 */
const sign = (note: string, tag: string): string =>
`<span>${note}</span><span>${tag} · <span data-slot="round_ref" data-blank></span></span>`;
/** 编辑闸 —— `label` 是闸上那句话(目标件的措辞各工单不同,如「AI 已识别 N 个客户」) */
const editGate = (address: string, label: string): string => `<div class="jc-editgate" data-edit-for="${address}">
<span class="jc-editgate-t">${label}</span>
<button type="button" class="jc-egbtn open" data-edit-open="${address}">编辑</button>
<button type="button" class="jc-egbtn ghost" data-edit-cancel="${address}">取消</button>
<button type="button" class="jc-egbtn save" data-edit-save="${address}">保存</button>
</div>`;
/* ==================== 局面一 · 候选覆盖客户清单(S1/S2)==================== */
const CANDIDATE_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:36px;min-width:36px"></th>
<th class="req" style="min-width:100px">客户编号</th>
<th class="req" style="min-width:170px">客户名称</th>
<th class="sug" style="min-width:110px">客户类型</th>
<th class="sug" style="width:150px;min-width:150px">BT 层级</th>
<th style="width:80px;min-width:80px">状态</th>
<th class="sug" style="min-width:200px;width:100%">识别依据 / 纳入原因</th>
<th class="sug" style="min-width:104px">覆盖深度</th>
</tr></thead>
<tbody data-rows="mine:${D}.candidate" data-edit="${D}.candidate"><tr>
<td class="jc-rn"><button type="button" class="jc-rowdel" data-edit-del="${D}.candidate" title="撤掉这一行">×</button></td>
<td class="req" 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" data-slot="customer_name" data-col="customer_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="BT 层级">
<select class="jc-cell" data-col="bt_level">
<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="bt_level"></span>
</td>
<td data-h="状态"><span class="jc-tag" data-class-of="status"></span></td>
<td class="sug" data-h="识别依据 / 纳入原因"><span class="jc-vcell" data-slot="basis" data-col="basis" data-blank></span></td>
<td class="sug" data-h="覆盖深度">
<select class="jc-cell" data-col="depth">
<option value=""></option>
<option value="deep">深度</option>
<option value="standard">标准</option>
<option value="light">轻度</option>
</select>
<span class="jc-tag" data-class-of="depth"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_SCOPE = doc({
tone: 'human',
badge: '执行',
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:
part('做什么', '确认本轮覆盖范围') +
`<div class="jc-derive">
<p><span class="done">AI 已完成</span>:按台账规则(覆盖度陈旧 / 决策链空白节点 / 事件触发)从 <b>L-02 客户主台账</b>与
<b>L-08 联系人台账</b>识别本轮触发口径与覆盖范围,产出下方客户清单(候选基线)。</p>
<p class="jc-note-line">本轮触发:<b data-slot="trigger_label" data-blank></b> <b data-slot="event_type" data-blank></b></p>
<p class="jc-note-line">本轮说明:<span data-slot="note" data-blank></span></p>
<p><span class="need">需要你查漏补缺</span>:点「编辑」直接在清单上增删改 —— <b>加一行 = 额外纳入</b>(AI 未识别但你有价值判断的客户)、
<b>删一行 = 排除</b>(AI 纳入但实际应排除)、<b>改单元格 = 修正</b> AI 识别的内容。没有要补的直接在对话区卡里提交。</p>
</div>` +
part('交回什么', '核对后的覆盖范围客户清单(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_candidate_missing">
<span><b>还差 <span data-slot="${D}_candidate_missing"></span> 行客户编号或客户名称没填</b>
—— <span data-slot="${D}_candidate_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.candidate`,
`<b>AI 已识别 <span data-slot="${D}_candidate_rows"></span> 家候选客户</b>,点「编辑」在清单上增删改`,
) +
`<div class="jc-editbar">
<span>加一行 = 额外纳入,删一行 = 排除,改单元格 = 修正 AI 识别的内容。
<b>客户编号</b>与<b>客户名称</b>是必填,缺一条这一单走不到经营计划那一步。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
CANDIDATE_TABLE +
`<div class="jc-addrow">
<button type="button" class="jc-egbtn ghost" data-edit-add="${D}.candidate">+ 加一行(额外纳入客户)</button>
<em>新加行的「客户编号」「客户名称」为必填;「识别依据」栏填你纳入它的原因。</em>
</div>` +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 1/2 · 覆盖范围确认', '工单类型:执行'),
});
/* ==================== 局面二 · 关键人经营计划(S3/S4,只读)==================== */
const PLAN_TABLE = `<div class="jc-tbl-wrap"><table class="jc-tbl-in">
<thead><tr>
<th style="width:40px;min-width:40px"></th>
<th style="min-width:140px">客户</th>
<th style="min-width:110px">关键人</th>
<th style="min-width:150px">建议动作</th>
<th style="width:92px;min-width:92px">时间点</th>
<th style="width:70px;min-width:70px">优先级</th>
<th style="min-width:200px;width:100%">AI 给出的理由</th>
</tr></thead>
<tbody data-rows="mine:${D}.plan">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="客户"><span class="jc-vcell cust" data-slot="customer_name" data-blank></span></td>
<td class="dimempty" data-h="关键人"><span class="jc-vcell" data-slot="contact_name" data-blank></span></td>
<td class="dimempty" data-h="建议动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td class="dimempty" data-h="时间点"><span class="jc-vcell mono" data-slot="target_date" data-blank></span></td>
<td data-h="优先级"><span class="jc-tag" data-class-of="priority"></span></td>
<td class="dimempty" data-h="AI 给出的理由"><span class="jc-vcell" data-slot="rationale" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* 比目标件多一列「时间点」(有意偏离):验收判据③让人核「动作有没有时间点」,
* 而目标件的表上没有这一列 —— 判据指向的东西必须摆在卡面上,人才核得了
* (target_date 在台账声明里是必填,数据一直都在,只是从前没摆出来)。
*/
export const COVERAGE_PLAN = doc({
tone: 'ledger',
badge: '验收',
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:
part('AI 结论说明', '结论是怎么算出来的') +
`<div class="jc-derive">
<p><b>AI 已完成的计算</b>:读取 L-02 客户画像、决策链地图、关系覆盖度,读取 L-08 联系人角色 / 影响力 / 亲密度;
对照决策链识别空白节点,按「影响力 × 亲密度缺口」排序,得出本轮<b>关系经营计划</b>。</p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这份计划是否合理,请逐项核对', [
'<b>关键人覆盖是否完整</b>:该经营的决策链关键人(BT-01 / BT-02 / BT-03)有没有漏掉;',
'<b>优先级排序是否合理</b>:是否符合「影响力 × 亲密度缺口」的排序逻辑,有没有高优的客户被排低了;',
'<b>动作是否可执行</b>:建议动作是否明确、有没有时间点、可不可落地。',
]) +
part('AI 产出的经营计划', '只读展示') +
PLAN_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过 / 驳回」请在对话区的【验收卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 1/2 · 关系经营计划审批', '工单类型:验收'),
});
/* ==================== 局面三 · 执行回执(S5)==================== */
const RECEIPT_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:140px">计划动作</th>
<th style="min-width:150px">对象</th>
<th class="req" style="width:110px;min-width:110px">完成状态</th>
<th class="req" style="min-width:200px">结果摘要</th>
<th class="sug" style="min-width:180px">客户反馈</th>
<th class="req" style="width:100px;min-width:100px">关键人变动</th>
</tr></thead>
<tbody data-rows="mine:${D}.receipt" data-edit="${D}.receipt"><tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="计划动作"><span class="jc-vcell" data-slot="action" data-blank></span></td>
<td data-h="对象">
<span class="jc-vcell"><b class="cust" data-slot="customer_name" data-blank></b> · <i data-slot="contact_name" data-blank></i></span>
<span class="jc-vcell mono jc-sub" data-slot="contact_code" data-col="contact_code" data-blank></span>
</td>
<td class="req" data-h="完成状态">
<select class="jc-cell" data-col="completion_status" required>
<option value=""></option>
<option value="done">完成</option>
<option value="partial">部分完成</option>
<option value="skipped">未执行</option>
</select>
<span class="jc-tag" data-class-of="completion_status"></span>
</td>
<td class="req" data-h="结果摘要">
<span class="jc-vcell" data-slot="result_summary" data-blank></span>
<textarea class="jc-cell" data-col="result_summary" placeholder="见到的关键人、谈了什么、达成什么"></textarea>
</td>
<td class="sug" data-h="客户反馈">
<span class="jc-vcell" data-slot="customer_feedback" data-blank></span>
<textarea class="jc-cell" data-col="customer_feedback" placeholder="客户态度 / 诉求 / 下一步信号"></textarea>
</td>
<td class="req" data-h="关键人变动">
<select class="jc-cell" data-col="key_person_change" required>
<option value=""></option>
<option value="changed">是</option>
<option value="unchanged">无变动</option>
</select>
<span class="jc-tag" data-class-of="key_person_change"></span>
</td>
</tr></tbody>
</table></div>`;
export const COVERAGE_RECEIPT = doc({
tone: 'human',
badge: '执行',
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:
part('做什么', '执行计划动作') +
`<div class="jc-derive">
<p><b>经营计划已验收通过</b>。以下是需要你<span class="need">到场执行</span>的动作(物理拜访、技术交流、递送实物 ——
都是 AI 做不到、需要人去干的)。每项动作执行后,在下方回执。</p>
</div>` +
part('交回什么', '逐项回执(*为必填)') +
`<div class="jc-gapbar" data-class-of="${D}_receipt_missing">
<span><b>还差 <span data-slot="${D}_receipt_missing"></span> 项回执没填齐</b>
—— <span data-slot="${D}_receipt_missing_keys"></span>。
按右上角「编辑」开表,红框那几格就是要填的。补齐了执行单才提交得了。</span>
</div>` +
editGate(
`${D}.receipt`,
`<b>共 <span data-slot="${D}_receipt_rows"></span> 项计划动作待回执</b>,点「编辑」逐项回填`,
) +
`<div class="jc-editbar">
<span>可填的是<b>完成状态</b>、<b>结果摘要</b>、<b>客户反馈</b>、<b>关键人变动</b>四列;
计划动作与对象是承继自计划的只读展示,改不了。确实没有变动的「关键人变动」请选「无变动」,不能留空。
改动只落任务草稿层,<b>保存不等于放行</b>。</span>
</div>` +
RECEIPT_TABLE +
flow('<b>审批 / 提交:</b>本工单只承载内容;「提交回执」请在对话区的【执行卡】中进行。若「关键人变动」标「是」,将触发台账写变更记录。'),
foot: sign('E04 关系覆盖与关键人经营 · 执行工单 2/2 · 经营计划执行回执', '工单类型:执行'),
});
/* ==================== 局面四 · 台账更新明细(S6/T_release,只读)==================== */
const LEDGER_UPDATE_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:150px">台账</th>
<th style="min-width:200px">字段</th>
<th style="min-width:140px">更新前</th>
<th style="min-width:140px;width:100%">更新后</th>
</tr></thead>
<tbody data-rows="mine:${D}.ledger_update">
<tr>
<td class="jc-rn"></td>
<td class="dimempty" data-h="台账">
<span class="jc-vcell mono" data-slot="ledger_label" data-blank></span>
<span class="jc-sub mono"><i data-slot="customer_code" data-blank></i> <i data-slot="contact_code" data-blank></i></span>
</td>
<td class="dimempty" data-h="字段">
<span class="jc-vcell mono" data-slot="field_label" data-blank></span>
<span class="jc-sub">依据:<i data-slot="evidence_ref" data-blank></i></span>
</td>
<td class="dimempty" data-h="更新前"><span class="jc-vcell dim" data-slot="before_value" data-blank></span></td>
<td data-h="更新后"><span class="jc-vcell jc-diffnew" data-slot="after_value" data-blank></span></td>
</tr>
</tbody>
</table></div>`;
/*
* 「字段」格里多一行「依据」、「台账」格里多一行编码(有意偏离目标件):三条 L4 判据
* 让签字的人核「有没有依据 / 有没有张冠李戴」,而 evidence_ref / customer_code 从前
* 压根不在卡面上 —— 人被要求判他看不见的东西。次行小字摆进原格,四列结构不动。
*/
export const COVERAGE_LEDGER_UPDATE = doc({
tone: 'ledger',
badge: '验收',
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)、
更新 L-08 联系人档案(角色 / 影响力 / 亲密度 / 关键人标记)、追加互动时间线。
本轮覆盖结论:<span data-slot="summary" data-blank></span></p>
</div>` +
part('验收判据', '你据此判断「通过 / 驳回」') +
criteria('判断这次台账更新是否无误,请逐项核对', [
'<b>更新是否有依据</b>:每条更新是否都能在「执行回执」里找到对应事实(如 coverage_score 上升对应哪几次拜访);',
'<b>数值是否合理</b>:coverage_score、亲密度等数值变化幅度与回执是否吻合;',
'<b>有无漏更或误更</b>:该更新的字段都更新了、不该动的没动。',
]) +
part('拟更新的台账', '只读展示') +
LEDGER_UPDATE_TABLE +
flow('<b>审批:</b>本工单只承载内容;「通过入库 / 驳回」请在对话区的【验收卡】中进行。'),
foot: sign('E04 关系覆盖与关键人经营 · 验收工单 2/2 · 关系台账更新确认', '工单类型:验收 → 完结'),
});