资料文件真产出来了;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,131 @@
/**
* 产出的资料**存档** —— 放行那一刻冻住的那一份(2026-08-23 用户:
* 「检查当前产出的附件是否存档,在工作成果里也需要有产出的附件,点击后可以预览」)。
*
* 存档之前的实情:文件是台账那一行现造的,而资料常落在**主数据**上,
* 试跑单放行时主数据只留在活动缓存、不落正式表 —— 于是**放行之后一份都点不开**
* (实测 404)。工作成果恰恰是收工之后才去看的地方,不存这一份,那一屏就是空的。
*/
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { archiveAssetFiles, keyText } from '../asset-archive.ts';
const TASK = 't-1' as TaskId;
/** 记下发生过哪些写 —— SQL 不求值,只看「几笔、带了什么」 */
function fakeDb() {
const writes: string[] = [];
return {
writes,
execute: async (query: unknown) => {
// drizzle 的 sql`` 片段:把它的字面量与参数拼成一句好断言的字符串
const q = query as { queryChunks?: unknown[] };
writes.push(JSON.stringify(q.queryChunks ?? query).slice(0, 4000));
return { rows: [] };
},
};
}
const ids = (() => {
let n = 0;
return {
next: () => {
n += 1;
return `id-${n}`;
},
};
})();
const ASSET = {
domain: 'content',
object: 'asset',
fields: [{ name: 'content_id', primaryKey: true }, { name: 'title' }, { name: 'body_md' }, { name: 'file_format' }],
file: { titleColumn: 'title', bodyColumn: 'body_md', formatColumn: 'file_format' },
};
describe('keyText —— 主键拼法必须稳定', () => {
it('按列名排序拼,写进去与取出来才对得上', () => {
expect(keyText({ b: '2', a: '1' })).toBe('a=1,b=2');
expect(keyText({ a: '1', b: '2' })).toBe('a=1,b=2');
});
it('单列主键就是一句 `列=值` —— 与件上 data-file 那一串同形状', () => {
expect(keyText({ content_id: 'CT-2026-0001' })).toBe('content_id=CT-2026-0001');
});
});
describe('archiveAssetFiles —— 存哪些、不存哪些', () => {
it('一行一份,存下来的是真字节(存几份就写几笔)', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [
{ content_id: 'CT-1', title: '白皮书', body_md: '# 白皮书\n\n正文', file_format: 'docx' },
{ content_id: 'CT-2', title: '对照表', body_md: '| a | b |\n| - | - |\n| 1 | 2 |', file_format: 'xlsx' },
],
});
expect(saved).toBe(2);
expect(db.writes).toHaveLength(2);
expect(db.writes.join()).toContain('白皮书');
});
it('**没正文的行不存** —— 一份空壳存进去,人点开只会看见一张白纸', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: '还没写', body_md: ' ', file_format: 'md' }],
});
expect(saved).toBe(0);
expect(db.writes).toHaveLength(0);
});
it('**主键不全的行不存** —— 存了也按不回那一行,取不回来', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: '', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('没声明 `file` 段的表整张跳过 —— 存档也认那道授权面', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [{ ...ASSET, file: undefined }],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'md' }],
});
expect(saved).toBe(0);
});
it('格式列填了个不认识的值:退 md,不整份丢掉', async () => {
const db = fakeDb();
const saved = await archiveAssetFiles({
db,
taskId: TASK,
ids,
declarations: [ASSET],
rowsOfAddress: async () => [{ content_id: 'CT-1', title: 'T', body_md: '正文', file_format: 'pdf' }],
});
expect(saved).toBe(1);
expect(db.writes.join()).toContain('"md"');
});
it('大多数活动不产资料 —— 一张带 file 段的表都没有时返回 0,不报错', async () => {
const db = fakeDb();
expect(
await archiveAssetFiles({ db, taskId: TASK, ids, declarations: [], rowsOfAddress: async () => [] }),
).toBe(0);
});
});
@@ -0,0 +1,210 @@
/**
* 资料文件现造 + 读回来 —— 两头合起来自证:**造出来的是真文件**。
*
* 断言的重点不是「函数返回了字节」,而是:
* · docx / xlsx 是合法 ZIP,里面那几份 XML 一份不缺(Word / Excel 认的就是它们);
* · 读回来的内容与写进去的一模一样 —— 渲得出来,才谈得上「前端能渲染」;
* · 模型写的字全程转义 —— 正文里带 `<script>` 也只是几个字,不是标签。
*/
import { describe, expect, it } from 'vitest';
import {
columnName,
composeFile,
inlineHtml,
markdownBody,
markdownSheetRows,
parseMarkdown,
safeFileName,
} from '../file-compose.ts';
import { docxBlocks, toFileDoc, xlsxRows } from '../file-doc.ts';
import { readZip } from '../upload-extract.ts';
const MD = [
'# 车规级 MCU 供货能力说明',
'',
'本文面向 **Itelma** 采购与供应链团队,说明我方在车规 MCU 上的供货保障。',
'',
'## 一、产能与库存',
'',
'- 常备安全库存:12 周',
'- 二供已导入,切换周期 4 周',
'',
'## 二、价格与交期',
'',
'| 型号 | 交期(周) | 年用量 |',
'| --- | --- | --- |',
'| NE555P | 8 | 30000 |',
'| DAG2400 | 12 | 25000 |',
'',
'---',
'',
'结论:可承诺 `98%` 的按期交付率。',
].join('\n');
describe('parseMarkdown —— 认得出这几样,认不出的不吞字', () => {
it('标题 / 段落 / 列表 / 表格 / 分隔线各归各位', () => {
const blocks = parseMarkdown(MD);
expect(blocks.map((b) => b.t)).toEqual(['h1', 'p', 'h2', 'ul', 'h2', 'table', 'hr', 'p']);
});
it('表格的分隔行只说对齐,不算一行数据', () => {
const table = parseMarkdown(MD).find((b) => b.t === 'table');
expect(table).toBeDefined();
if (table?.t !== 'table') throw new Error('不是表格块');
expect(table.rows).toEqual([
['型号', '交期(周)', '年用量'],
['NE555P', '8', '30000'],
['DAG2400', '12', '25000'],
]);
});
it('认不出的行当段落原样留着 —— 宁可朴素也不吞内容', () => {
const blocks = parseMarkdown('> 引用还不认\n\n普通段落');
expect(blocks).toEqual([
{ t: 'p', text: '> 引用还不认' },
{ t: 'p', text: '普通段落' },
]);
});
});
describe('转义 —— 模型写的字进不了标签位', () => {
it('正文里的 <script> 只是几个字', () => {
const html = markdownBody('这里有 <script>alert(1)</script> 一段');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>');
});
it('先转义再上行内标记 —— 顺序反了就是把字当标签执行', () => {
expect(inlineHtml('**粗** 与 `码` 与 <b>假标签</b>')).toBe(
'<strong>粗</strong> 与 <code>码</code> 与 &lt;b&gt;假标签&lt;/b&gt;',
);
});
});
describe('md —— 原文即文件', () => {
it('正文没有标题行就补一条:文件得自报家门', () => {
const f = composeFile({ title: '供货能力说明', markdown: '正文直接开始', format: 'md' });
expect(new TextDecoder().decode(f.bytes)).toBe('# 供货能力说明\n\n正文直接开始');
expect(f.fileName).toBe('供货能力说明.md');
});
it('正文自带标题就不再补', () => {
const f = composeFile({ title: 'T', markdown: MD, format: 'md' });
expect(new TextDecoder().decode(f.bytes).startsWith('# 车规级 MCU')).toBe(true);
});
});
describe('html —— 整份文件,离线打开也是这个样子', () => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'html' });
const text = new TextDecoder().decode(f.bytes);
it('是一份完整文档:doctype + 自带样式 + 纸面容器', () => {
expect(text.startsWith('<!doctype html>')).toBe(true);
expect(text).toContain('<style>');
expect(text).toContain('class="doc-paper"');
});
it('表格是真表格,不是一段文字', () => {
expect(text).toContain('<th>型号</th>');
expect(text).toContain('<td>NE555P</td>');
});
});
describe('docx —— 真 OOXML 包', () => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format: 'docx' });
const zip = readZip(f.bytes);
it('该有的几份 XML 一份不缺(少一份 Word 就报文件损坏)', () => {
expect([...zip.keys()].sort()).toEqual(
['[Content_Types].xml', '_rels/.rels', 'word/_rels/document.xml.rels', 'word/document.xml', 'word/styles.xml'].sort(),
);
});
it('MIME 与扩展名是 Word 那一套', () => {
expect(f.fileName).toBe('供货能力说明.docx');
expect(f.mime).toContain('wordprocessingml.document');
});
it('读回来:标题、正文、列表、表格都在,层级没丢', () => {
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
const blocks = docxBlocks(xml);
expect(blocks[0]).toEqual({ t: 'h1', text: '供货能力说明' });
expect(blocks.some((b) => b.t === 'h2' && b.text === '一、产能与库存')).toBe(true);
const list = blocks.find((b) => b.t === 'ul');
expect(list?.t === 'ul' ? list.items : []).toEqual(['常备安全库存:12 周', '二供已导入,切换周期 4 周']);
// 表格按行摊成 p:列用「 | 」连
expect(blocks.some((b) => b.t === 'p' && b.text === 'NE555P | 8 | 30000')).toBe(true);
});
it('行内标记不带进 Word —— 留着 ** 反而像乱码', () => {
const xml = new TextDecoder().decode(zip.get('word/document.xml') ?? new Uint8Array());
expect(xml).toContain('Itelma');
expect(xml).not.toContain('**Itelma**');
});
});
describe('xlsx —— 真 OOXML 包,表格原样成行', () => {
const f = composeFile({ title: '交期对照表', markdown: MD, format: 'xlsx' });
it('该有的几份 XML 一份不缺', () => {
expect([...readZip(f.bytes).keys()].sort()).toEqual(
['[Content_Types].xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels', 'xl/workbook.xml', 'xl/worksheets/sheet1.xml'].sort(),
);
});
it('读回来:表格三行在,首行是表头', () => {
const sheet = xlsxRows(f.bytes);
expect(sheet?.sheet).toBe('交期对照表');
const rows = sheet?.rows ?? [];
expect(rows[0]).toEqual(['交期对照表']);
expect(rows).toContainEqual(['型号', '交期(周)', '年用量']);
expect(rows).toContainEqual(['NE555P', '8', '30000']);
});
it('正文不丢:列表与段落各占一行', () => {
const rows = markdownSheetRows('T', MD);
expect(rows.some((r) => r[0] === '• 常备安全库存:12 周')).toBe(true);
expect(rows.some((r) => (r[0] ?? '').startsWith('结论:可承诺'))).toBe(true);
});
it('列号:0→A、25→Z、26→AA', () => {
expect([columnName(0), columnName(25), columnName(26)]).toEqual(['A', 'Z', 'AA']);
});
});
describe('toFileDoc —— 前端拿到的就是这份文件读回来的样子', () => {
const one = (format: 'md' | 'html' | 'docx' | 'xlsx') => {
const f = composeFile({ title: '供货能力说明', markdown: MD, format });
return toFileDoc({ name: f.fileName, title: '供货能力说明', format, bytes: f.bytes });
};
it('四种格式各出各的查看形态', () => {
expect(one('md')?.kind).toBe('markdown');
expect(one('html')?.kind).toBe('html');
expect(one('docx')?.kind).toBe('word');
expect(one('xlsx')?.kind).toBe('excel');
});
it('markdown 同时给原文与渲好的那一份(下载拿原文,屏上摆渲染版)', () => {
const doc = one('md');
if (doc?.kind !== 'markdown') throw new Error('不是 markdown');
expect(doc.md).toContain('| NE555P | 8 | 30000 |');
expect(doc.html).toContain('<td>NE555P</td>');
});
it('**读不出来就返回 undefined** —— 不摆一个空壳假装渲染过', () => {
const junk = new TextEncoder().encode('这不是 ZIP');
expect(toFileDoc({ name: 'x.docx', title: 'x', format: 'docx', bytes: junk })).toBeUndefined();
expect(toFileDoc({ name: 'x.xlsx', title: 'x', format: 'xlsx', bytes: junk })).toBeUndefined();
});
});
describe('文件名', () => {
it('路径分隔符这类字符换成下划线 —— 别让标题决定文件落在哪', () => {
expect(safeFileName('a/b:c*d?"e<f>g|h', 'md')).toBe('a_b_c_d__e_f_g_h.md');
});
it('标题整个是空白就退一个兜底名,不出「.docx」这种没名字的文件', () => {
expect(safeFileName(' ', 'docx')).toBe('资料.docx');
});
});
@@ -19,7 +19,7 @@
* 抄一份的下场踩过 —— 生产改了、测没跟着改,测全绿而线上照旧。
*/
import { describe, expect, it } from 'vitest';
import { gateAddressesOf, gateOf, rowChanged, writesOf } from '../routes/desk.ts';
import { findKnownRow, gateAddressesOf, gateOf, parseRowKey, resolveRowKey, rowChanged, writesOf } from '../routes/desk.ts';
import { missingReadings } from '../wiring/engines.ts';
/** A1 受理记录的声明(裁到这一段用得着的几列):PN 与年用量必填 */
@@ -68,6 +68,85 @@ describe('件上按保存:按地址收', () => {
});
});
/**
* **人在件上加的那一行,主键得凑得齐** —— 凑不齐就被静默丢掉,而屏上那一行还在
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。
*
* 三类主键各有各的凑法(2026-08-23,审 I01 需求清单时逮到的):
* · **件上没摆的那一列**(如一单一个的案号):本单现有行全表一致 → 就是它;
* · **件上摆了的**:人填了就用他填的;
* · **序号型(整数主键)**:件上摆着,可「加一行」那一下所有格子都被清空
* (`ViewContainer.addRow` 如此)——各行序号本就互不相同,「全表一致」永远取不出,
* 于是整行被丢。序号的语义就是「本轮内递增」,该由基座续:现有最大值 + 1。
* I01 的需求清单主键正是 `round_ref + req_seq`,不补这一手,人加的需求就消失。
*/
describe('件上加行:主键凑得齐才落得下', () => {
const KEYS = ['round_ref', 'req_seq'];
const TYPES = { round_ref: 'text', req_seq: 'integer' };
const DRAFTS = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-2', req_seq: 2 } },
];
const SAME_ROUND = [
{ key: { round_ref: 'CTB-1', req_seq: 1 } },
{ key: { round_ref: 'CTB-1', req_seq: 2 } },
];
it('老行:件上摆了序号,案号从全表一致里补', () => {
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '2' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('**新增行:序号空着 → 基座续号(现有最大 + 1)**,不再静默丢掉', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: SAME_ROUND, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '3',
});
});
it('空表上加第一行:序号从 1 起 —— 但案号取不出就还是不猜', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '公司介绍' }, drafts: [], types: TYPES })).toBeUndefined();
expect(
resolveRowKey({ keys: KEYS, cells: { round_ref: 'CTB-9', content_type: '公司介绍' }, drafts: [], types: TYPES }),
).toEqual({ round_ref: 'CTB-9', req_seq: '1' });
});
/*
* **只有一行时,那一行的序号不是「全表一致的常量」**(2026-08-23 浏览器实测逮到:
* 人在只有一行的清单上加一行,新行拿到 req_seq=1,**当场把原来那一行覆盖掉** ——
* 屏上两行、库里一行,而且内容还成了新的那份)。序号逐行不同是它的定义,
* 一致只是行数太少的假象。
*/
it('**表里只有一行:加的新行续成 2,不覆盖原来那一行**', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '销售工具包' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '2',
});
});
it('老行照旧认得出来 —— 件上摆了序号(主键列必挂 data-col,它是行的钥匙)', () => {
const one = [{ key: { round_ref: 'CTB-1', req_seq: 1 } }];
expect(resolveRowKey({ keys: KEYS, cells: { req_seq: '1', scene: '展会' }, drafts: one, types: TYPES })).toEqual({
round_ref: 'CTB-1',
req_seq: '1',
});
});
it('案号本身取不出(一单跨了两个号)→ 不猜,如实跳过', () => {
expect(resolveRowKey({ keys: KEYS, cells: { content_type: '案例集' }, drafts: DRAFTS, types: TYPES })).toBeUndefined();
});
it('**文本主键不续号** —— 序号能续是因为它本就是「第几条」,PN 这种续不出来', () => {
const textKeys = ['rfq_ref', 'pn'];
const textTypes = { rfq_ref: 'text', pn: 'text' };
const drafts = [{ key: { rfq_ref: 'R1', pn: 'A' } }, { key: { rfq_ref: 'R1', pn: 'B' } }];
expect(resolveRowKey({ keys: textKeys, cells: { eau: '100' }, drafts, types: textTypes })).toBeUndefined();
});
});
describe('提交闸:必填没填就交不了', () => {
const gate = (rows: Array<Record<string, unknown>>) =>
gateOf(missingReadings({ 'mine:quote_intake.record': rows }, [RECORD]), [RECORD]);
@@ -298,3 +377,64 @@ describe('自增主键:前缀-两位序号', () => {
expect(gen([], {}, 4, ['seq'])).toEqual({ seq: '05' });
});
});
describe('主键摆不全时认现有行:一格都没认出来的碎片不许命中随便第一行', () => {
const drafts = [
{ key: { round_ref: 'R1', contact_code: 'CU-0142-01' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-02' } },
{ key: { round_ref: 'R1', contact_code: 'CU-0087-01' } },
];
const keys = ['round_ref', 'contact_code'];
it('至少一格对得上 → 命中那一行(正常路径)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-0087-02' })?.key).toEqual({
round_ref: 'R1',
contact_code: 'CU-0087-02',
});
});
it('**一格主键都没给的碎片**(如渲染多嵌一层导致 collect() 拆出的空键分组)→ 不命中第一行,回 undefined', () => {
expect(findKnownRow(drafts, keys, {})).toBeUndefined();
});
it('给了但一个都对不上 → 同样不命中(不是「随便一行」,是「哪行都不是」)', () => {
expect(findKnownRow(drafts, keys, { contact_code: 'CU-9999-01' })).toBeUndefined();
});
});
/**
* **点开一份资料文件时,那一行是怎么认出来的**(2026-08-23)。
*
* 件上传来的 `key` 是一串字符,而"哪一行"是主键说了算。这一段的要害是
* **只认声明里的主键列** —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
*/
describe('parseRowKey —— 件上传来的主键认哪一行', () => {
it('单列主键:直接给值', () => {
expect(parseRowKey('CT-2026-0001', ['content_id'])).toEqual({ content_id: 'CT-2026-0001' });
});
it('多列主键:列=值 以逗号连,顺序无所谓', () => {
expect(parseRowKey('req_seq=2,round_ref=BR-1', ['round_ref', 'req_seq'])).toEqual({
round_ref: 'BR-1',
req_seq: '2',
});
});
it('**声明里没有的列名一律不认** —— 别让一串 key 把别的行取走', () => {
expect(parseRowKey('content_id=A,secret=1', ['content_id'])).toBeUndefined();
});
it('主键给不全:不认(宁可打不开,也不打开错的那一份)', () => {
expect(parseRowKey('round_ref=BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
expect(parseRowKey('round_ref=BR-1,req_seq=', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('多列主键却只给了个裸值:不认(猜不出它是哪一列)', () => {
expect(parseRowKey('BR-1', ['round_ref', 'req_seq'])).toBeUndefined();
});
it('空串 / 没有主键列:不认', () => {
expect(parseRowKey('', ['content_id'])).toBeUndefined();
expect(parseRowKey('X', [])).toBeUndefined();
});
});
+7 -1
View File
@@ -884,7 +884,13 @@ export function normalizeAction(object: unknown, enabled: EnabledSet): AgentActi
* 这里只是不让一个词的叫法差异吃掉一整拍。
*/
const kind = String(raw['kind'] ?? raw['action'] ?? '');
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? '');
/*
* 项号兜底连**进行中**的项一起看(2026-08-23):从前只兜「当下可为」的第一项,
* 而依赖断点那一拍它恰恰是空的 —— 兜出来的是空串,兜底卡于是带着空项号发出去,
* 被引擎自己的门拦下(最后的活路自己被砌了墙)。引擎那一侧已改成「挂不上照发」,
* 这里再挑得准一点:在场的项优先挂上,记账才对得上。
*/
const itemId = String(raw['itemId'] ?? enabled.entries[0]?.id ?? enabled.inProgress[0]?.id ?? '');
switch (kind) {
case 'start':
+163
View File
@@ -0,0 +1,163 @@
/**
* 产出的资料文件**存档** —— 放行那一刻把它冻住。
*
* 在办期间文件是台账那一行的投影(现造,见 `file-compose.ts`):台账改一格,文件跟着变,
* 两处永远一致 —— 那时这样最好。**收工之后不该再变**:归档件说的是「他签字那一刻
* 交出去的是什么」,台账后来被下一单改了,归档的那份不能跟着改口。
*
* 还有一条更硬的理由:资料常落在**主数据**上(如 `content.asset` 品牌内容库),
* 而试跑单放行时主数据只留在活动缓存、不落正式表(免得试跑往正式库里塞真资产)——
* 不存这一份,试跑单一放行,人在工作成果里就什么都点不开了。
*
* 一单一行一份,重复放行覆盖(不留两份说法不一的档)。
*/
import { sql } from 'drizzle-orm';
import type { TaskId } from '@contracts';
import { composeFile, isFileFormat, type FileFormat } from './file-compose.ts';
/** 存档里的一份 —— 列表用(不带字节) */
export type ArchivedAssetFile = {
readonly id: string;
readonly address: string;
readonly key: Record<string, string>;
readonly title: string;
readonly format: FileFormat;
readonly name: string;
readonly size: number;
readonly at: string;
};
type Db = { execute(query: unknown): Promise<unknown> };
function rowsOf<T>(result: unknown): T[] {
if (Array.isArray(result)) return result as T[];
const rows = (result as { rows?: unknown }).rows;
return Array.isArray(rows) ? (rows as T[]) : [];
}
/**
* 主键 → 一个**稳定的**字符串(按列名排序后拼)。
* 稳定是要害:存的时候与取的时候拼法不一样,那一行就永远对不上。
*/
export function keyText(key: Readonly<Record<string, unknown>>): string {
return Object.keys(key)
.sort()
.map((k) => `${k}=${String(key[k] ?? '')}`)
.join(',');
}
/** 这一单已存档的资料(不含字节) */
export async function listArchived(db: Db, taskId: TaskId): Promise<ArchivedAssetFile[]> {
const rows = rowsOf<{
id: string;
address: string;
row_key: unknown;
title: string;
format: string;
name: string;
size: number;
created_at: unknown;
}>(
await db.execute(sql`
select id, address, row_key, title, format, name, size, created_at
from sys.task_asset_file where task_id = ${taskId} order by created_at, name`),
);
return rows.map((r) => ({
id: r.id,
address: r.address,
key: (typeof r.row_key === 'object' && r.row_key !== null ? r.row_key : {}) as Record<string, string>,
title: r.title,
format: isFileFormat(r.format) ? r.format : 'md',
name: r.name,
size: Number(r.size ?? 0),
at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
}));
}
/** 取存档里的那一份字节 —— 按 (单, 地址, 主键) 认 */
export async function readArchived(
db: Db,
taskId: TaskId,
address: string,
key: Readonly<Record<string, string>>,
): Promise<{ bytes: Uint8Array; title: string; format: FileFormat; name: string } | undefined> {
const rows = rowsOf<{ bytes: unknown; title: string; format: string; name: string }>(
await db.execute(sql`
select bytes, title, format, name from sys.task_asset_file
where task_id = ${taskId} and address = ${address} and row_key = ${JSON.stringify(key)}::jsonb
limit 1`),
);
const one = rows[0];
if (one === undefined) return undefined;
const raw = one.bytes;
/*
* **必须拷一份成独立的 Uint8Array** —— pg 取回来的是 Node `Buffer`,
* 而 Buffer 挂在一块**共享的 64KB 内存池**上:`buf.slice()` 是视图不是拷贝,
* `.buffer` 拿到的是整个池子。直接交出去,一份 3KB 的文件会变成 65536 字节
* (后面全是别人的残渣)—— 实测下载三份资料,三份都是 65536。
* `Uint8Array.from` 按元素拷,新数组的 buffer 恰好这么长。
*/
const bytes =
raw instanceof Uint8Array
? Uint8Array.from(raw)
: typeof raw === 'string'
? Uint8Array.from(Buffer.from(raw, 'hex'))
: undefined;
if (bytes === undefined) return undefined;
return { bytes, title: one.title, format: isFileFormat(one.format) ? one.format : 'md', name: one.name };
}
/**
* 把这一单产出的资料**全部存一份**。放行落地之前调 —— 那时草稿还在、人的修订也已落回,
* 存下来的正是他签字那一刻的样子。
*
* 返回存了几份;一份都没有(活动没声明 `file` 段、或一行都没写)时返回 0,不报错 ——
* 大多数活动本来就不产资料文件。
*/
export async function archiveAssetFiles(input: {
readonly db: Db;
readonly taskId: TaskId;
readonly ids: { next(): string };
/** 活动清单声明的台账对象(带 `file` 段的才算) */
readonly declarations: ReadonlyArray<{
domain: string;
object: string;
fields: ReadonlyArray<{ name: string; primaryKey?: boolean }>;
file?: { titleColumn: string; bodyColumn: string; formatColumn: string } | undefined;
}>;
/** 本单在该地址写下的行(正式版 + 本单草稿) */
readonly rowsOfAddress: (address: { domain: string; object: string }) => Promise<Array<Record<string, unknown>>>;
}): Promise<number> {
let saved = 0;
for (const declared of input.declarations) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey === true).map((f) => f.name);
if (keys.length === 0) continue;
const rows = await input.rowsOfAddress({ domain: declared.domain, object: declared.object });
for (const row of rows) {
const markdown = String(row[spec.bodyColumn] ?? '').trim();
if (markdown === '') continue; // 没正文的行不是资料,别存一份空壳
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue; // 主键不全的行认不出来,存了也取不回
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
const composed = composeFile({ title, markdown, format });
const address = `${declared.domain}.${declared.object}`;
await input.db.execute(sql`
insert into sys.task_asset_file (id, task_id, address, row_key, title, format, name, bytes, size)
values (${input.ids.next()}, ${input.taskId}, ${address}, ${JSON.stringify(key)}::jsonb,
${title}, ${format}, ${composed.fileName}, ${Buffer.from(composed.bytes)}, ${composed.bytes.length})
on conflict (task_id, address, row_key) do update set
title = excluded.title, format = excluded.format, name = excluded.name,
bytes = excluded.bytes, size = excluded.size, created_at = now()`);
saved += 1;
}
}
return saved;
}
+1 -1
View File
@@ -18,7 +18,7 @@ export type AppConfig = {
readonly seedPassword: string;
};
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai';
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai_wos';
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
+425
View File
@@ -0,0 +1,425 @@
/**
* 资料文件**现造** —— 进「标题 + Markdown 正文 + 要哪种格式」,出真字节。
*
* 为什么是现造而不是另存一份文件:资料的正身是**内容**,而内容本来就落在台账里。
* 再存一份字节,同一份事实两处各存一份 —— 改了台账那份文件就成了旧的,而没人看得出
* 以哪份为准。现造的这一份永远与台账一致,下载拿到的又是真 .docx / .xlsx
* (Word / Excel 打得开),两头都不将就。
*
* 不引第三方库 —— 与 `upload-extract.ts` 同一条路:docx / xlsx 本质是 ZIP 包着几份 XML,
* 写一个只做 store(不压缩)的 ZIP 打包器就够了,为一件事拉进一整个文档库不值当。
*
* 这一份是**纯计算**:进字符串、出字节,不碰库不碰网,所以能逐条测。
*/
import { crc32Of, zipStore } from './zip-write.ts';
export const FILE_FORMATS = ['md', 'html', 'docx', 'xlsx'] as const;
export type FileFormat = (typeof FILE_FORMATS)[number];
export const isFileFormat = (v: unknown): v is FileFormat =>
typeof v === 'string' && (FILE_FORMATS as readonly string[]).includes(v);
/** 一份造好的文件 —— 字节 + 它该叫什么 + 该以什么 MIME 交出去 */
export type ComposedFile = {
readonly bytes: Uint8Array;
readonly fileName: string;
readonly mime: string;
readonly format: FileFormat;
};
/** 各格式该以什么 MIME 交出去 —— 存档那一路也认它(`desk.ts`) */
export const MIME_OF: Record<FileFormat, string> = {
md: 'text/markdown; charset=utf-8',
html: 'text/html; charset=utf-8',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/* ==================== Markdown → 结构块 ==================== */
export type MdBlock =
| { readonly t: 'h1' | 'h2' | 'h3' | 'p'; readonly text: string }
/* ul / ol 各成一员 —— 合成 `'ul' | 'ol'` 一个成员时,判过这两个分支
TS 也不会把它从联合里剔掉,后面取 `text` 就通不过 */
| { readonly t: 'ul'; readonly items: readonly string[] }
| { readonly t: 'ol'; readonly items: readonly string[] }
| { readonly t: 'table'; readonly rows: ReadonlyArray<readonly string[]> }
| { readonly t: 'hr' };
const TABLE_SPLIT = /\s*\|\s*/;
/** 一行表格 `| a | b |` → ['a','b'](首尾的竖线不算列) */
function tableCells(line: string): string[] {
const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return inner.split(TABLE_SPLIT).map((c) => c.trim());
}
const isTableLine = (line: string): boolean => line.trim().startsWith('|') && line.trim().endsWith('|');
/** 分隔行 `|---|:--:|` —— 它只说对齐,不是数据 */
const isTableRule = (line: string): boolean => isTableLine(line) && /^[\s|:-]+$/.test(line);
/**
* Markdown 子集 → 块。**认的就是这几样**:标题(#/##/###)、无序列表(-/*)、
* 有序列表(1.)、表格(GFM)、分隔线(---)、段落。
*
* 为什么只认一个子集:这些文件是给人看的资料(白皮书 / 规格书 / 对照表),
* 上面这几样够写;认得越多、docx/xlsx 那一侧要对应的东西越多,而每多一样
* 都是一处可能与前端渲染不一致的地方。**认不出的一律当段落原样保留**,不吞字。
*/
export function parseMarkdown(md: string): MdBlock[] {
const lines = md.replace(/\r\n?/g, '\n').split('\n');
const blocks: MdBlock[] = [];
let para: string[] = [];
const flushPara = (): void => {
if (para.length === 0) return;
blocks.push({ t: 'p', text: para.join(' ').trim() });
para = [];
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? '';
const trimmed = line.trim();
if (trimmed === '') {
flushPara();
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
flushPara();
blocks.push({ t: 'hr' });
continue;
}
const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed);
if (heading !== null) {
flushPara();
const depth = (heading[1] ?? '#').length;
blocks.push({ t: depth === 1 ? 'h1' : depth === 2 ? 'h2' : 'h3', text: (heading[2] ?? '').trim() });
continue;
}
if (isTableLine(trimmed)) {
flushPara();
const rows: string[][] = [];
while (i < lines.length && isTableLine(lines[i] ?? '')) {
const one = lines[i] ?? '';
if (!isTableRule(one)) rows.push(tableCells(one));
i += 1;
}
i -= 1;
if (rows.length > 0) blocks.push({ t: 'table', rows });
continue;
}
if (/^[-*+]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^[-*+]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ul', items });
continue;
}
if (/^\d+[.)]\s+/.test(trimmed)) {
flushPara();
const items: string[] = [];
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i] ?? '')) {
items.push((lines[i] ?? '').trim().replace(/^\d+[.)]\s+/, ''));
i += 1;
}
i -= 1;
blocks.push({ t: 'ol', items });
continue;
}
para.push(trimmed);
}
flushPara();
return blocks;
}
/* ==================== 行内:粗体 / 斜体 / 代码 ==================== */
export function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 行内标记 → HTML。**先转义再上标记** —— 顺序反了就是把模型写的字当标签执行 */
export function inlineHtml(text: string): string {
return escapeXml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
}
/** 行内标记**去掉**(docx / xlsx 的文本格里不做富文本,留记号反而像乱码) */
export function inlinePlain(text: string): string {
return text
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
}
/* ==================== md → HTML(前端渲染也用这一份) ==================== */
/**
* 正文骨架 —— 只吐这几种标签,内容全程转义:摆进沙箱是安全的,
* 而且前端 Markdown 查看面与下载到的 .html 长得一模一样(同一段函数出的)。
*/
export function markdownBody(md: string): string {
return parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<hr />';
if (b.t === 'ul') return `<ul>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ul>`;
if (b.t === 'ol') return `<ol>${b.items.map((i) => `<li>${inlineHtml(i)}</li>`).join('')}</ol>`;
if (b.t === 'table') {
const [head, ...rest] = b.rows;
const thead =
head === undefined ? '' : `<thead><tr>${head.map((c) => `<th>${inlineHtml(c)}</th>`).join('')}</tr></thead>`;
const tbody = rest.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c)}</td>`).join('')}</tr>`).join('');
return `<div class="doc-tbl"><table>${thead}<tbody>${tbody}</tbody></table></div>`;
}
return `<${b.t}>${inlineHtml(b.text)}</${b.t}>`;
})
.join('\n');
}
/** 纸面样式 —— 下载的 .html 与前端查看面共用,两处看着是同一份文件 */
export const DOC_CSS = `
:root { color-scheme: light; }
body { margin: 0; background: #f4f5f7; }
.doc-paper { max-width: 820px; margin: 0 auto; padding: 46px 56px 60px; background: #fff; color: #1f2329;
font: 14px/1.85 "PingFang SC", "Microsoft YaHei", -apple-system, "Segoe UI", sans-serif; }
.doc-paper h1 { font-size: 25px; line-height: 1.35; margin: 0 0 18px; padding-bottom: 12px; border-bottom: 2px solid #1f2329; }
.doc-paper h2 { font-size: 18px; margin: 30px 0 10px; padding-left: 10px; border-left: 3px solid #3370ff; }
.doc-paper h3 { font-size: 15px; margin: 22px 0 8px; color: #3d4046; }
.doc-paper p { margin: 0 0 11px; }
.doc-paper ul, .doc-paper ol { margin: 0 0 12px; padding-left: 22px; }
.doc-paper li { margin: 3px 0; }
.doc-paper hr { border: 0; border-top: 1px solid #dfe1e5; margin: 22px 0; }
.doc-paper code { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px;
background: #f2f3f5; padding: 1px 5px; border-radius: 4px; }
.doc-paper strong { font-weight: 700; }
.doc-tbl { overflow-x: auto; margin: 0 0 14px; }
.doc-paper table { border-collapse: collapse; width: 100%; font-size: 13px; }
.doc-paper th, .doc-paper td { border: 1px solid #dfe1e5; padding: 7px 10px; text-align: left; vertical-align: top; }
.doc-paper th { background: #f7f8fa; font-weight: 700; }
@media (max-width: 720px) { .doc-paper { padding: 26px 20px 34px; } }
`.trim();
/** 整份 HTML 文件(下载的那一份 —— 自带样式,离线打开也是这个样子) */
export function markdownHtmlDoc(title: string, md: string): string {
return `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeXml(title)}</title>
<style>${DOC_CSS}</style></head>
<body><article class="doc-paper">${markdownBody(md)}</article></body></html>`;
}
/* ==================== md → docx(真 OOXML) ==================== */
const DOCX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>`;
const DOCX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`;
const DOCX_DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`;
/** 只定义用到的那几个段落样式 —— Word 靠 styleId 认标题层级(导航窗格据此出目录) */
const DOCX_STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:pPr><w:outlineLvl w:val="0"/><w:spacing w:before="280" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:pPr><w:outlineLvl w:val="1"/><w:spacing w:before="240" w:after="100"/></w:pPr><w:rPr><w:b/><w:sz w:val="26"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="ListParagraph"><w:name w:val="List Paragraph"/><w:pPr><w:ind w:left="420"/></w:pPr></w:style>
</w:styles>`;
const wRun = (text: string): string =>
`<w:r><w:t xml:space="preserve">${escapeXml(inlinePlain(text))}</w:t></w:r>`;
const wPara = (text: string, style?: string): string =>
`<w:p>${style === undefined ? '' : `<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`}${wRun(text)}</w:p>`;
/** 表格:一行一 `<w:tr>`,首行加底纹当表头 —— Word 里是真表格,能选能改 */
function wTable(rows: ReadonlyArray<readonly string[]>): string {
const cols = Math.max(...rows.map((r) => r.length), 1);
const width = Math.floor(9000 / cols);
const cell = (text: string, head: boolean): string =>
`<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${head ? '<w:shd w:val="clear" w:fill="F2F3F5"/>' : ''}</w:tcPr>` +
`<w:p>${head ? '<w:pPr><w:rPr><w:b/></w:rPr></w:pPr>' : ''}${wRun(text)}</w:p></w:tc>`;
const body = rows
.map(
(r, ri) =>
`<w:tr>${Array.from({ length: cols }, (_, ci) => cell(r[ci] ?? '', ri === 0)).join('')}</w:tr>`,
)
.join('');
return (
`<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>` +
`<w:tblBorders>${['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((s) => `<w:${s} w:val="single" w:sz="4" w:color="DFE1E5"/>`)
.join('')}</w:tblBorders></w:tblPr>${body}</w:tbl>`
);
}
export function markdownDocxXml(title: string, md: string): string {
const body = parseMarkdown(md)
.map((b) => {
if (b.t === 'hr') return '<w:p><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="6" w:color="DFE1E5"/></w:pBdr></w:pPr></w:p>';
if (b.t === 'ul') return b.items.map((i) => wPara(`• ${i}`, 'ListParagraph')).join('');
if (b.t === 'ol') return b.items.map((i, n) => wPara(`${n + 1}. ${i}`, 'ListParagraph')).join('');
if (b.t === 'table') return wTable(b.rows);
if (b.t === 'h1') return wPara(b.text, 'Heading1');
if (b.t === 'h2') return wPara(b.text, 'Heading2');
if (b.t === 'h3') return wPara(b.text, 'Heading2');
return wPara(b.text);
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${wPara(title, 'Title')}${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr></w:body></w:document>`;
}
/* ==================== md → xlsx(真 OOXML) ==================== */
const XLSX_CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`;
const XLSX_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`;
const XLSX_WB_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`;
const xlsxWorkbook = (sheetName: string): string => `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="${escapeXml(sheetName).slice(0, 31)}" sheetId="1" r:id="rId1"/></sheets></workbook>`;
/** 列号 0 → A,26 → AA */
export function columnName(index: number): string {
let n = index + 1;
let out = '';
while (n > 0) {
const rem = (n - 1) % 26;
out = String.fromCharCode(65 + rem) + out;
n = Math.floor((n - 1) / 26);
}
return out;
}
/**
* 表格化:**表格块原样成行**,其余块各占一行(标题前空一行)——
* 一份规格书导成 Excel,人要的是那几张对照表,而不是把正文丢掉。
*/
export function markdownSheetRows(title: string, md: string): string[][] {
const rows: string[][] = [[title]];
for (const b of parseMarkdown(md)) {
if (b.t === 'hr') continue;
if (b.t === 'table') {
rows.push([]);
for (const r of b.rows) rows.push(r.map((c) => inlinePlain(c)));
continue;
}
if (b.t === 'ul') {
for (const i of b.items) rows.push([`• ${inlinePlain(i)}`]);
continue;
}
if (b.t === 'ol') {
b.items.forEach((i, n) => rows.push([`${n + 1}. ${inlinePlain(i)}`]));
continue;
}
if (b.t === 'h1' || b.t === 'h2' || b.t === 'h3') rows.push([]);
rows.push([inlinePlain(b.text)]);
}
return rows;
}
/** 值全部按 inlineStr 写 —— 不建共享串表:少一份要对齐的索引,少一处能错的地方 */
function xlsxSheet(rows: ReadonlyArray<readonly string[]>): string {
const body = rows
.map((cells, ri) => {
const tds = cells
.map((v, ci) =>
v === ''
? ''
: `<c r="${columnName(ci)}${ri + 1}" t="inlineStr"><is><t xml:space="preserve">${escapeXml(v)}</t></is></c>`,
)
.join('');
return `<row r="${ri + 1}">${tds}</row>`;
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${body}</sheetData></worksheet>`;
}
/* ==================== 出口 ==================== */
/** 文件名里不许出现的字符(跨平台取交集),一律换成下划线 */
export function safeFileName(title: string, format: FileFormat): string {
const base = title.replace(/[\\/:*?"<>|\n\r\t]/g, '_').trim().slice(0, 80) || '资料';
return `${base}.${format}`;
}
export function composeFile(input: {
readonly title: string;
readonly markdown: string;
readonly format: FileFormat;
}): ComposedFile {
const { title, markdown, format } = input;
const enc = new TextEncoder();
const common = { fileName: safeFileName(title, format), mime: MIME_OF[format], format };
if (format === 'md') {
// 原文里没有标题行就补一条 —— 文件得自报家门(台账那一列是标题,正文常常直接从正文起)
const head = /^\s*#\s+/.test(markdown) ? '' : `# ${title}\n\n`;
return { ...common, bytes: enc.encode(head + markdown) };
}
if (format === 'html') return { ...common, bytes: enc.encode(markdownHtmlDoc(title, markdown)) };
if (format === 'docx') {
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(DOCX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(DOCX_RELS)],
['word/_rels/document.xml.rels', enc.encode(DOCX_DOC_RELS)],
['word/styles.xml', enc.encode(DOCX_STYLES)],
['word/document.xml', enc.encode(markdownDocxXml(title, markdown))],
]),
};
}
return {
...common,
bytes: zipStore([
['[Content_Types].xml', enc.encode(XLSX_CONTENT_TYPES)],
['_rels/.rels', enc.encode(XLSX_RELS)],
['xl/_rels/workbook.xml.rels', enc.encode(XLSX_WB_RELS)],
['xl/workbook.xml', enc.encode(xlsxWorkbook(title))],
['xl/worksheets/sheet1.xml', enc.encode(xlsxSheet(markdownSheetRows(title, markdown)))],
]),
};
}
export { crc32Of };
+121
View File
@@ -0,0 +1,121 @@
/**
* 造好的资料文件**读回来给前端渲染** —— 进字节,出 `FileDoc`。
*
* 为什么绕这一圈,而不是直接把 Markdown 原文丢给前端按格式摆:
* **那样界面渲的就不是那份文件了**。人下载到的是 .docx,屏上看到的是另一条路
* 拼出来的样子,两边哪天不一致谁也发现不了。这里读的就是 `composeFile` 出的那一份
* 字节:docx 解出段落、xlsx 解出行列 —— 渲得出来,才证明那份文件是真的。
*
* 纯计算:进字节、出结构,不碰库不碰网。
*/
import type { FileDoc } from '@contracts';
import { markdownHtmlDoc, type FileFormat } from './file-compose.ts';
import { readZip, sharedStrings, sheetToRows, unescapeXml } from './upload-extract.ts';
/** docx 的一段:`<w:p>` 里所有 `<w:t>` 连起来就是这一段的文字 */
function docxParagraphs(xml: string): Array<{ style: string; text: string }> {
const out: Array<{ style: string; text: string }> = [];
for (const p of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
const inner = p[1] ?? '';
const style = /<w:pStyle\s+w:val="([^"]+)"/.exec(inner)?.[1] ?? '';
const text = [...inner.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join('');
out.push({ style, text });
}
return out;
}
/** 查看面认得的块 —— 与 `FileDoc` 的 word 分支同形 */
export type WordBlock = { t: 'h1' | 'h2' | 'p'; text: string } | { t: 'ul'; items: string[] };
/**
* docx → 查看面的块。表格在 `<w:tbl>` 里,而查看面(`FileViewer` 的 word 分支)
* 只认 h1/h2/p/ul —— 表格按行摊成 `p`(用「 | 」连列),**宁可朴素也不丢内容**。
*/
export function docxBlocks(xml: string): WordBlock[] {
const blocks: WordBlock[] = [];
let bullets: string[] = [];
const flush = (): void => {
if (bullets.length === 0) return;
blocks.push({ t: 'ul', items: bullets });
bullets = [];
};
// 表格与段落按它们在文档里的先后走:一次扫过,谁先出现算谁
for (const node of xml.matchAll(/<w:tbl\b[\s\S]*?<\/w:tbl>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/g)) {
const chunk = node[0];
if (chunk.startsWith('<w:tbl')) {
flush();
for (const tr of chunk.matchAll(/<w:tr\b[^>]*>([\s\S]*?)<\/w:tr>/g)) {
const cells = [...(tr[1] ?? '').matchAll(/<w:tc\b[^>]*>([\s\S]*?)<\/w:tc>/g)].map((tc) =>
[...(tc[1] ?? '').matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
);
if (cells.some((c) => c !== '')) blocks.push({ t: 'p', text: cells.join(' | ') });
}
continue;
}
const [para] = docxParagraphs(chunk);
if (para === undefined) continue;
const { style, text } = para;
if (text === '') continue;
if (style === 'ListParagraph') {
bullets.push(text.replace(/^[•\d]+[.、]?\s*/, ''));
continue;
}
flush();
if (style === 'Title' || style === 'Heading1') blocks.push({ t: 'h1', text });
else if (style === 'Heading2') blocks.push({ t: 'h2', text });
else blocks.push({ t: 'p', text });
}
flush();
return blocks;
}
/** xlsx → 行列(第一张表)。读的是**真包**:解不开就如实说,不编一张表出来 */
export function xlsxRows(bytes: Uint8Array): { rows: string[][]; sheet: string } | undefined {
const zip = readZip(bytes);
if (zip.size === 0) return undefined;
const dec = new TextDecoder('utf-8');
const sharedRaw = zip.get('xl/sharedStrings.xml');
const shared = sharedRaw === undefined ? [] : sharedStrings(dec.decode(sharedRaw));
const first = [...zip.keys()].filter((k) => /^xl\/worksheets\/sheet\d+\.xml$/.test(k)).sort()[0];
if (first === undefined) return undefined;
const raw = zip.get(first);
if (raw === undefined) return undefined;
const wbRaw = zip.get('xl/workbook.xml');
const sheet =
wbRaw === undefined ? 'Sheet1' : unescapeXml(/<sheet\s+name="([^"]*)"/.exec(dec.decode(wbRaw))?.[1] ?? 'Sheet1');
return { rows: sheetToRows(dec.decode(raw), shared), sheet };
}
/**
* 字节 → `FileDoc`。**读不出来就返回 undefined** —— 上层据此如实说
* 「这份文件打不开」,不摆一个空壳假装渲染过(界面不撒谎)。
*/
export function toFileDoc(input: {
readonly name: string;
readonly title: string;
readonly format: FileFormat;
readonly bytes: Uint8Array;
}): FileDoc | undefined {
const { name, title, format, bytes } = input;
const dec = new TextDecoder('utf-8');
if (format === 'html') return { kind: 'html', name, html: dec.decode(bytes) };
if (format === 'md') {
const md = dec.decode(bytes);
// 原文与渲好的一起交:查看面摆渲染版,而「下载」拿到的仍是这段原文
return { kind: 'markdown', name, md, html: markdownHtmlDoc(title, md) };
}
if (format === 'xlsx') {
const sheet = xlsxRows(bytes);
if (sheet === undefined) return undefined;
return { kind: 'excel', name, sheet: sheet.sheet, rows: sheet.rows };
}
const zip = readZip(bytes);
const doc = zip.get('word/document.xml');
if (doc === undefined) return undefined;
const blocks = docxBlocks(dec.decode(doc));
if (blocks.length === 0) return undefined;
return { kind: 'word', name, blocks };
}
+2
View File
@@ -30,6 +30,8 @@ const RUN_TABLES = [
*/
'sys.task_comm',
'sys.upload',
// 产出的资料文件存档 —— 同样是跑出来的、同样没有指向 task 的外键
'sys.task_asset_file',
'sys.node_instance',
// 全链 Trace:span 是运行痕,与工作流同生共死 —— 清运行数据不清它,观测面会挂着一堆孤儿
'sys.trace_span',
+383 -36
View File
@@ -5,26 +5,32 @@
*/
import { sql } from 'drizzle-orm';
import type { Hono } from 'hono';
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
import type { ApiError, ChainNode, ChatMessage, DecisionCard, Deliverable, FileDoc, TaskCard, TaskId, ViewBlock, WorkOrder } from '@contracts';
import {
answerMessageRoute,
chatCardReplySchema,
confirmTriggerPendingRoute,
deliverableIdSchema,
listMessagesRoute,
markMessageReadRoute,
listTasksRoute,
markCommReadRoute,
replyChatCardRoute,
replyWorkOrderRoute,
openAssetFileRoute,
saveTableRoute,
sayRoute,
taskDetailRoute,
staffIdSchema,
taskIdSchema,
workOrderIdSchema,
workOrderReplySchema,
} from '@contracts';
import { ReplyRejectedError } from '@modules/human-tool';
import { advanceOrder } from '@modules/process';
import { keyText, listArchived, readArchived } from '../asset-archive.ts';
import { composeFile, isFileFormat, MIME_OF, safeFileName, type FileFormat } from '../file-compose.ts';
import { toFileDoc } from '../file-doc.ts';
import { displayLog } from './log-lines.ts';
import { pulse } from '../workers/pulse.ts';
import type { Container } from '../container.ts';
@@ -60,6 +66,70 @@ const merge = (into: WriteBack, one: WriteBack): void => {
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
*/
/**
* **人在件上改的/加的这一行,主键怎么凑齐** —— 凑不齐就落不下去,而屏上那一行还在
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。三类主键三种凑法:
*
* ① 件上摆了、人也填了 → 就用他填的(主键只认行、不改行);
* ② **件上压根没摆的**(A1 的 `rfq_ref`、I01 的 `round_ref`:一单一个,不占版面)——
* 先按行认(`findKnownRow`:件上给了别的主键列就拿它去现有行里找),
* 认不出再看这一列在本单现有行里是不是**全表一致**,是就是它;
* 一单跨了两个号 → 取不出,如实跳过,不猜;
* ③ **序号型(整数主键)**——「加一行」那一下所有格子都被清空
* (`ViewContainer.addRow` 如此),而各行序号本就互不相同,②那条永远取不出,
* 于是整行被丢(2026-08-23 审 I01 需求清单 `round_ref + req_seq` 时逮到)。
* 序号的语义就是「本轮内第几条」,该由基座续:现有最大值 + 1,空表从 1 起。
* **只续新增行**:老行的序号件上摆着、人填着,走①就定了。
*/
export function resolveRowKey(input: {
keys: readonly string[];
cells: Readonly<Record<string, string>>;
drafts: ReadonlyArray<{ key: Record<string, unknown> }>;
types: Readonly<Record<string, string>>;
}): Record<string, string> | undefined {
const { keys, cells, drafts, types } = input;
const key: Record<string, string> = {};
for (const k of keys) {
const v = cells[k];
if (v !== undefined && v.trim() !== '') key[k] = v.trim();
}
if (Object.keys(key).length === keys.length) return key;
const known = findKnownRow(drafts, keys, key);
const isSeq = (column: string): boolean => types[column] === 'integer' || types[column] === 'number';
/*
* **序号型主键不走「全表一致」这条路**(2026-08-23 浏览器实测逮到的):
* 表里只有一行时,那一行的序号在「全表一致」眼里就是个常量 —— 人加的新行会拿到
* 同一个号,**当场覆盖掉原来那一行**(屏上两行,库里一行,而且内容还是新的那份)。
* 序号逐行不同是它的定义,一致只是行数太少的假象;它该走下面的续号那一支。
*/
for (const k of keys) {
if (key[k] !== undefined) continue;
if (known === undefined && isSeq(k)) continue;
const v =
known === undefined
? (() => {
const seen = new Set(drafts.map((d) => String(d.key[k] ?? '')));
const only = [...seen][0];
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
})()
: String(known.key[k] ?? '');
if (v !== undefined && v !== '') key[k] = v;
}
if (Object.keys(key).length === keys.length) return key;
// ③ 续号:只对整数型主键,且这一行确实是新增的(前两条都没认出来)
for (const k of keys) {
if (key[k] !== undefined) continue;
if (!isSeq(k)) continue;
const used = drafts
.map((d) => Number(d.key[k]))
.filter((n) => Number.isFinite(n));
key[k] = String((used.length === 0 ? 0 : Math.max(...used)) + 1);
}
return Object.keys(key).length === keys.length ? key : undefined;
}
async function writeEditedRows(
container: Container,
taskId: TaskId,
@@ -71,30 +141,16 @@ async function writeEditedRows(
const out = nothing();
// 这一单现有的行 —— 三处要用:补主键(件上没摆全的那几列)、认出哪几行真改了、认出哪几行是新增
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
/*
* **件上压根没摆的那几列主键**(A1 的 `rfq_ref` 就是:一单一个单号,不占版面)——
* 本单现有行里那一列若全表一致,就是这一单的那个值,新增行照它填。
* 不一致(一单跨了两个单号)就取不出来:那时新增行认不出自己是谁,如实跳过,不猜。
*/
const constant = (column: string): string | undefined => {
const seen = new Set(drafts.map((d) => String(d.key[column] ?? '')));
const only = [...seen][0];
return seen.size === 1 && only !== undefined && only !== '' ? only : undefined;
};
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
for (const cells of lines) {
/*
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
*/
const key: Record<string, string> = {};
let complete = true;
for (const k of keys) {
const v = cells[k];
if (v === undefined || v.trim() === '') complete = false;
else key[k] = v;
}
const key = resolveRowKey({ keys, cells, drafts, types });
/*
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
*
@@ -113,22 +169,7 @@ async function writeEditedRows(
if (coerced.kind === 'value') patch[name] = coerced.value;
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
}
if (!complete) {
/*
* 主键没摆全 —— 两种情形,先按行认,认不出再按列补:
* · 老行:件上给了 PN,单号那一列没摆 → 拿 PN 去本单现有行里找,取它的单号;
* · **新增行**:PN 是新的,现有行里一个都对不上 → 那几列取全表一致的那个值。
* 少了后一半,人在件上加的行会**静默丢掉**:主键凑不齐,这里一 continue 就没了,
* 而屏上那一行还在 —— 界面说加上了,台账里没有。
*/
const known = drafts.find((d) => keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]));
for (const k of keys) {
if (key[k] !== undefined) continue;
const v = known === undefined ? constant(k) : String(known.key[k] ?? '');
if (v !== undefined && v !== '') key[k] = v;
}
}
if (Object.keys(key).length !== keys.length) continue;
if (key === undefined) continue;
const was = drafts.find((d) => keys.every((k) => String(d.key[k] ?? '') === key[k]));
if (!rowChanged(was === undefined ? undefined : { ...was.key, ...was.patch }, patch)) continue;
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
@@ -172,6 +213,27 @@ async function dropEditedRows(
return out;
}
/**
* **主键摆不全时,认这一行是本单现有的哪一行**(2026-08-21 修死)。
*
* `key` 是这一格已经认出来的那几个主键值(可能一个都没有)。**一格都没认出来的碎片
* 不许命中「随便找到的第一行」**——`.find()` 的谓词对着全 `undefined` 的 `key` 恒真,
* 会把它错配给 `drafts` 里第一条,而那条八成风马牛不相及(手写的 radio/select 组
* 因为渲染嵌套多一层,曾经被 `collect()` 拆成不带主键的碎片,现已在 `ViewContainer`
* 那一侧修死——这里补的是第二道:即便未来哪天又出现类似的欠主键碎片,也不会被这个
* `.find()` 悄悄按到错误的行上)。至少要有一格真对得上,不然交给调用方去按列补
* (`constant`),对不上就整行跳过。
*/
export function findKnownRow<K extends Record<string, unknown>>(
drafts: ReadonlyArray<{ key: K }>,
keys: readonly string[],
key: Readonly<Record<string, string>>,
): { key: K } | undefined {
return drafts.find(
(d) => keys.some((k) => key[k] !== undefined) && keys.every((k) => key[k] === undefined || String(d.key[k]) === key[k]),
);
}
/**
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
*
@@ -318,6 +380,68 @@ async function landingOf(
};
}
/**
* 这一单产出的**资料文件**一摞 —— 工作成果里与定稿报告并列。
*
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
*/
async function assetFilesOf(
container: Container,
taskId: TaskId,
): Promise<Array<{ address: string; key: string; title: string; format: string; name: string; at?: string; archived: boolean }>> {
const archived = await listArchived(container.db, taskId).catch(() => []);
if (archived.length > 0) {
return archived.map((a) => ({
address: a.address,
key: keyText(a.key),
title: a.title,
format: a.format,
name: a.name,
at: a.at,
archived: true,
}));
}
const declarations = await declarationsOf(container, taskId);
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
for (const declared of declarations ?? []) {
const spec = declared.file;
if (spec === undefined) continue;
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
if (keys.length === 0) continue;
const page = await container.ledger
.taskView(taskId, {
address: { domain: declared.domain, object: declared.object },
where: {},
orderBy: [],
limit: 500,
offset: 0,
})
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
for (const row of page.rows) {
// 没正文的行不是资料 —— 摆出来点开只会落一句「还没产出」
if (String(row[spec.bodyColumn] ?? '').trim() === '') continue;
const key: Record<string, string> = {};
for (const k of keys) key[k] = String(row[k] ?? '');
if (Object.values(key).some((v) => v === '')) continue;
const rawFormat = String(row[spec.formatColumn] ?? '').trim().toLowerCase();
const format = isFileFormat(rawFormat) ? rawFormat : 'md';
const title = String(row[spec.titleColumn] ?? '').trim() || Object.values(key).join('-');
out.push({
address: `${declared.domain}.${declared.object}`,
key: keyText(key),
title,
format,
name: safeFileName(title, format),
archived: false,
});
}
}
return out;
}
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
async function declarationsOf(container: Container, taskId: TaskId) {
const task = await container.task.get(taskId).catch(() => undefined);
@@ -326,6 +450,30 @@ async function declarationsOf(container: Container, taskId: TaskId) {
return manifest?.declarations.ledger;
}
/**
* 件上传来的主键 → 一行的主键对。
*
* 两种写法:单列主键直接给值(`CT-2026-0001`),多列主键写 `列=值` 以 `,` 连
* (`round_ref=BR-2026...,req_seq=1`)。**认的是声明里的主键列**,件上说的列名
* 若不在其中一律不认 —— 不然一个精心构造的 key 就能把别的行当成"这一份资料"取走。
*/
export function parseRowKey(raw: string, keys: readonly string[]): Record<string, string> | undefined {
if (keys.length === 0) return undefined;
if (!raw.includes('=')) {
const only = keys[0];
return keys.length === 1 && only !== undefined && raw !== '' ? { [only]: raw } : undefined;
}
const out: Record<string, string> = {};
for (const pair of raw.split(',')) {
const at = pair.indexOf('=');
if (at === -1) continue;
const col = pair.slice(0, at).trim();
if (!keys.includes(col)) return undefined;
out[col] = pair.slice(at + 1).trim();
}
return keys.every((k) => out[k] !== undefined && out[k] !== '') ? out : undefined;
}
/**
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
*
@@ -725,6 +873,43 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
return gate === undefined ? order : { ...order, gate };
}),
);
/*
* **决策卡答完的报告也算工作成果**(2026-08-21,`decisionCardSchema.finalHtml`)。
*
* `decide` 是唯一能循环的中间验收机制(`issueAcceptance` 一个任务只能发一次),
* 而它不是工单 —— 答完那一刻没有 `sys.work_order` 行,工作成果那一屏从前只认
* 工单,人在这张卡上通过的报告(如 E04 的经营计划)永远看不到。这里把答过的
* 决策卡拼成同一形状的条目,**单独一个字段**,不并进 `workOrders`:
* `mode:'acceptance'` 在别处(`ViewRegion.tsx` 的 `signed`)是「终局放行卡」的
* 同义词——那道判断认的是「有一张办结的验收单 + 还没有终态」等于「字签下了正在落地」,
* 前提是 `issueAcceptance` 只发一次、acceptance 恒是那一张。混进去会让 E04 一过
* S4(中间点,离终态还有 S5/S6 一大截)就误亮「正在落地」。前端只在工作成果那一屏
* 把两份列表并显,别处仍只读 `workOrders`。
*
* 三值口径(见 E04 case-model 的注释:`approve`/`reject`/`custom:` 开头)粗分
* 「通过 = pass,别的都算驳回」,只影响列表上那一点点颜色,不影响任何机检或放行 ——
* 那道门永远是 `issueAcceptance` 走的放行卡,这里只是回看用的一条历史记录。
*/
const decided: WorkOrder[] = cards
.filter((c): c is DecisionCard => c.k === 'decision')
.flatMap((c) => {
const { chosen, finalHtml } = c;
if (chosen === undefined || finalHtml === undefined) return [];
return [
{
id: workOrderIdSchema.parse(c.id),
at: c.at,
title: c.ask,
mode: 'acceptance' as const,
basis: c.basis,
deliverable: deliverableIdSchema.parse(c.id),
criteria: [],
fields: [],
finalHtml,
done: { at: chosen.at, by: chosen.by, result: chosen.value === 'approve' ? ('pass' as const) : ('redo' as const) },
},
];
});
/*
* 沟通记录 —— 一单往来的原件。附件那一格照抄基座读出来的 `note`(读出多少字 /
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
@@ -839,6 +1024,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
results,
comms,
workOrders: gated,
decidedDeliverables: decided,
assetFiles: await assetFilesOf(container, taskId),
chat: merged,
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
...(pushed?.html === undefined ? {} : { viewHtml: pushed.html }),
@@ -859,6 +1046,125 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
* **工单不办结**:保存只是保存。放行门的位置一点没变 ——
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
*/
/*
* **点开一份资料文件** —— 件上的资料名点下去,走到这里。
*
* 三道关,一道都不能省:
* ① 会话(是不是这个人);
* ② **授权面 = 活动清单的台账声明** —— 那张表没声明 `file` 段就不给,
* 与「写看台账声明」「立监听看清单声明」同款;声明里没有的列名也不认;
* ③ 行取的是**任务视图**(正式版 + 本单草稿)—— 在办的初稿本来就还在草稿层,
* 只读正式表的话,人在审的这一份恰恰打不开。
*
* 拿到行之后**现造**:标题 + 正文 + 格式 → 真字节。`raw=1` 原样交出去(下载),
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
*/
app.get(openAssetFileRoute.path, async (c) => {
const staff = await me(c.get('account'));
if (staff === undefined) {
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
return c.json(body, 401);
}
const taskId = taskIdSchema.parse(c.req.param('taskId'));
const address = c.req.query('address') ?? '';
const rawKey = c.req.query('key') ?? '';
const [domain = '', object = ''] = address.split('.');
/* 单没了就直说单没了 —— 不然下面那句会把「这一单不在了」说成「活动没声明」 */
const task = await container.task.get(taskId).catch(() => undefined);
if (task === undefined) {
const body: ApiError = { code: 'not-found', message: '这一单已经不在了' };
return c.json(body, 404);
}
const declarations = await declarationsOf(container, taskId);
const declared = declarations?.find((d) => d.domain === domain && d.object === object);
if (declared?.file === undefined) {
const body: ApiError = {
code: 'forbidden',
message: `这个活动没有把「${address}」声明成资料文件(清单的 declarations.ledger[].file)——不声明就点不开`,
};
return c.json(body, 403);
}
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
const key = parseRowKey(rawKey, keys);
if (key === undefined) {
const body: ApiError = { code: 'validation-failed', message: `认不出这一行:主键要给全 ${keys.join('、')}` };
return c.json(body, 400);
}
/*
* **先看存档** —— 放行那一刻这一份就冻住了(`asset-archive.ts`)。
*
* 顺序是有讲究的:归档件上点开的必须是**他签字那一刻的那一份**,
* 而不是台账后来被下一单改成的样子。在办期间还没有存档,自然落到下面现造那条路。
*/
const archived = await readArchived(container.db, taskId, address, key).catch(() => undefined);
if (archived !== undefined) {
if (c.req.query('raw') === '1') {
return c.body(archived.bytes.slice().buffer as ArrayBuffer, 200, {
'content-type': MIME_OF[archived.format],
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(archived.name)}`,
});
}
const doc = toFileDoc({ name: archived.name, title: archived.title, format: archived.format, bytes: archived.bytes });
if (doc !== undefined) return c.json({ file: doc, fileName: archived.name, format: archived.format });
}
const page = await container.ledger
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
const row = page.rows.find((r) => keys.every((k) => String(r[k] ?? '') === key[k]));
if (row === undefined) {
/*
* **收了工再来找不到,多半是试跑那条路**(2026-08-23 实测):
* 资料这一行若落在**主数据**上,试跑单放行时它只留在活动缓存、不落正式表
* (免得试跑往品牌内容库里塞真资产,见 ledger 的 `simulation` 那段注释)——
* 于是放行前点得开、放行后点不开。正式单不受此限(放行合并进正式表,照旧查得到)。
* 两种情形说清楚,别拿一句「可能已被撤掉」把人支去翻列表找一行根本不会在的东西。
*/
const body: ApiError = {
code: 'not-found',
message:
task.terminal === undefined
? '这一行不在了 —— 可能已被撤掉,回列表看一眼'
: '这一单已经收工,查不到这一行了 —— 若它是试跑单,放行时主数据只留在活动缓存、不落正式表' +
'(试跑不往正式库里塞真资产),这份资料在放行前查得到;正式单不受此限',
};
return c.json(body, 404);
}
const text = (col: string): string => String(row[col] ?? '').trim();
const title = text(declared.file.titleColumn) || keys.map((k) => key[k]).join('-');
const markdown = text(declared.file.bodyColumn);
const rawFormat = text(declared.file.formatColumn).toLowerCase();
/* 格式列没填 / 填了个不认识的:退 md —— 有正文就该看得见,不因为一格没填就整份打不开 */
const format: FileFormat = isFileFormat(rawFormat) ? rawFormat : 'md';
if (markdown === '') {
const body: ApiError = {
code: 'not-found',
message: `这一份还没有正文(${declared.file.bodyColumn} 是空的)—— 资料还没产出,不是打不开`,
};
return c.json(body, 404);
}
const composed = composeFile({ title, markdown, format });
if (c.req.query('raw') === '1') {
return c.body(composed.bytes.slice().buffer as ArrayBuffer, 200, {
'content-type': composed.mime,
// 文件名走 RFC 5987:中文名在各家浏览器上才落得对
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(composed.fileName)}`,
});
}
const doc = toFileDoc({ name: composed.fileName, title, format, bytes: composed.bytes });
if (doc === undefined) {
const body: ApiError = { code: 'internal', message: `这份 ${format} 造出来了却读不回来 —— 已留痕,请报给开发` };
return c.json(body, 500);
}
return c.json({ file: doc, fileName: composed.fileName, format });
});
app.post(saveTableRoute.path, async (c) => {
const staff = await me(c.get('account'));
if (staff === undefined) {
@@ -1012,6 +1318,17 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
...(card.also ?? []).map((g) => ({ ledger: g.ledger, key: g.key })),
];
const declarations = (await declarationsOf(container, cardTaskId)) ?? [];
/*
* **跨段共享主键**(2026-08-20 修死):卡面注释早就写着「两段的 customer_code
* 都留空:人在第一段填了什么,第二段跟着是什么」,但代码从没兑现过这句话 ——
* `also` 段的 `key` 只取卡面写死的字面占位(通常就是那个空字符串),从不看
* 人刚在**上一段**填的值。建一位新客户时,公司档正确落了 `customer_code`
* (它作为一个 `field` 进了 patch),联系人档却带着空字符串的 `customer_code`
* 落库 —— 新联系人从落地那一刻起就没挂在这位客户名下,`autoKeys` 算
* `contact_code` 时也因为拿不到前缀,生成裸序号(`01`)而不是
* `CU-0201-01`。现在按段落定的主键值顺着传给后面的段。
*/
const sharedKey: Record<string, string> = {};
for (const [at, group] of groups.entries()) {
const [domain, object] = group.ledger.split('.');
if (domain === undefined || object === undefined) continue;
@@ -1024,8 +1341,19 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
}
if (Object.keys(patch).length === 0) continue;
const declared = declarations.find((l) => l.domain === domain && l.object === object);
const key = { ...group.key, ...(await autoKeys(container, cardTaskId, declared, group.key)) };
// 本段的非自增主键:这一段自己填的(patch)优先,没填就接上更早那段定下的值
const ownKeyFields = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
const given: Record<string, string> = { ...group.key };
for (const name of ownKeyFields) {
const value = patch[name] ?? sharedKey[name];
if (value !== undefined && value !== '') given[name] = value;
}
const key = { ...given, ...(await autoKeys(container, cardTaskId, declared, given)) };
await container.ledger.draftWrite({ taskId: cardTaskId, address: { domain, object }, key, patch });
for (const name of ownKeyFields) {
const value = key[name];
if (value !== undefined && value !== '') sharedKey[name] = value;
}
}
{
/*
@@ -1036,6 +1364,25 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
*/
}
}
/*
* **决策卡答完那一刻,把当时的件原样存进这张卡**(与工单同一条道理,见
* `decisionCardSchema.finalHtml` 的注释)。`decide` 是唯一能循环的中间验收
* 机制,E04 的经营计划审批(S4_plan_approval)就是靠它——没有这一段,
* 人在这张卡上通过的报告永远进不了工作成果(那一屏从前只认工单)。
* 快照失败不拦人答卡(留痕即可)。
*/
if (parsed.data.k === 'decision' && card.k === 'decision') {
try {
const shot = await container.interaction.viewOf(cardTaskId);
if (shot?.html !== undefined) {
await container.db.execute(
sql`update sys.chat_card set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.cardId}`,
);
}
} catch {
/* 件还没推过就没有快照 —— 不拦人答卡 */
}
}
/*
* 人答完了就接着跑 —— **录入卡尤其要紧**:它是人按下确认那一刻,
* 眼睛正盯着屏幕。等两轮轮询(实测 6.8 秒)那段时间对话区一动不动,
+46 -2
View File
@@ -720,7 +720,19 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const taskId = taskIdSchema.parse(c.req.param('taskId'));
const task = await container.task.get(taskId);
/*
* **「这一单没了」是 404,不是 500**(2026-08-23 用户:「推进完成了,但测试台还在
* 运行中,没有停下来」查出来的后一半)。这一单被别处清掉(reset-runs / 另一台起单
* 先销毁上一单)之后,这条路由抛的是普通 Error → 兜底成 `500 internal`。
* 而 500 的意思是「服务器出毛病了,等会儿再来」——面板据此**继续举着上一次的状态
* 每 2 秒重试**,屏上就成了「AWAITING-EVENT · 自动刷新」配一条「任务不存在」。
* 状态码说错一格,前端就分不清「该松手」与「该等它回来」。
*/
const task = await container.task.get(taskId).catch(() => undefined);
if (task === undefined) {
const body: ApiError = { code: 'not-found', message: `这一单已经不在了(${taskId})` };
return c.json(body, 404);
}
const memory = await container.memory.window(taskId, 200);
const drafts = await container.ledger.taskDrafts(taskId);
@@ -1246,7 +1258,20 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const faces = await Promise.all(
cards.map(async (card) => {
const manifest = await container.registry.manifestOf(card.activityId);
type FieldSpec = { type?: string; items?: { type?: string }; title?: string; format?: string };
type FieldSpec = {
type?: string;
items?: { type?: string };
title?: string;
format?: string;
/** 枚举值 —— 声明了就该给下拉选,不该让人对着自由文本框猜合法值 */
enum?: unknown[];
/** 枚举的人读名(与 `enum` 同序;JSON Schema 无此标准关键字,借业界通行写法) */
enumNames?: unknown[];
/** JSON Schema 标准关键字:示例值,填不出合法值时给个能抄的样子 */
examples?: unknown[];
/** 非标准扩展:另一个字段等于某值才出现这一格(级联下拉那类场景) */
showWhen?: { field?: string; equals?: string };
};
const data = manifest.contract.input.data as {
required?: string[];
properties?: Record<string, FieldSpec>;
@@ -1256,6 +1281,17 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
/** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */
const typeOf = (spec: FieldSpec | undefined): string =>
spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown');
/** 枚举 → 下拉选项:有 `enumNames` 配对就给人读名,没有就拿枚举值本身当标签 */
const optionsOf = (spec: FieldSpec | undefined): { value: string; label: string }[] | undefined => {
if (!Array.isArray(spec?.enum) || spec.enum.length === 0) return undefined;
return spec.enum.map((v, i) => ({ value: String(v), label: String(spec.enumNames?.[i] ?? v) }));
};
/** 示例值:JSON Schema `examples` 第一项,对象/数组类型序列化成能直接抄的 JSON 串 */
const exampleOf = (spec: FieldSpec | undefined): string | undefined => {
const first = spec?.examples?.[0];
if (first === undefined) return undefined;
return typeof first === 'string' ? first : JSON.stringify(first);
};
const portFields = (payload: unknown) => {
const p = payload as { properties?: Record<string, FieldSpec> } | undefined;
return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) }));
@@ -1270,6 +1306,14 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
...(spec?.title === undefined ? {} : { label: spec.title }),
// `format: 'file'` = 这一格收的是**真文件**,界面该给文件选择器而不是文本框
...(spec?.format === undefined ? {} : { format: spec.format }),
// 声明了枚举就给下拉选 —— 不该让人对着自由文本框猜合法值
...(optionsOf(spec) === undefined ? {} : { options: optionsOf(spec) }),
// 声明了示例就把它摆进占位提示,比裸类型名(`string`)更能抄
...(exampleOf(spec) === undefined ? {} : { example: exampleOf(spec) }),
// 声明了 showWhen 就只在另一格等于某值时才出现(级联下拉)
...(spec?.showWhen?.field === undefined || spec.showWhen.equals === undefined
? {}
: { showWhen: { field: spec.showWhen.field, equals: spec.showWhen.equals } }),
})),
// 包自带的样例集:试跑一个活动时照它预填(对着空表单编,编出来的多半跑不通)
samples: manifest.samples.map((sample) => ({ name: sample.name, input: sample.input })),
+23 -15
View File
@@ -30,6 +30,8 @@ const REAL_PACKAGES = [
'quote-pricing',
'quote-negotiate',
'quote-archive',
'crm-relationship-coverage',
'brand-content-toolkit',
] as const;
/**
@@ -65,21 +67,6 @@ export async function installRealActivities(container: Container): Promise<void>
// 冲突要如实报:静默吞掉的话,表结构和声明对不上而没人知道
(conflicts.length === 0 ? '' : ` —— 合并冲突 ${conflicts.length} 处:${conflicts.map((c) => c.field).join('、')}`),
);
/*
* 一位样例客户 —— 让「老客户认得出」那条路试得出来(见 `MASTER_SAMPLE_ROWS` 的注)。
* 按主键写,重复播种是覆盖不是堆叠;写正式版(不是草稿层),
* 因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
*/
for (const row of MASTER_SAMPLE_ROWS) {
await container.ledger.systemWrite({
address: { domain: row.address.domain, object: row.address.object },
key: { ...row.key },
values: { ...row.values },
cause: { kind: 'governance', ref: 'seed:master-sample' },
});
}
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
const root = activitiesRoot();
for (const dir of REAL_PACKAGES) {
@@ -135,4 +122,25 @@ export async function installRealActivities(container: Container): Promise<void>
log(` 活动包 ${dir} 未过安装两审:${error instanceof Error ? error.message : String(error)}`);
}
}
/*
* **样例客户 —— 装完全部活动包之后再播种**(2026-08-20 改序,踩过坑):
* `MASTER_SAMPLE_ROWS` 里带着 E04 追加的列(`cust_type`/`decision_chain`…)——
* 这些列要等 `crm-relationship-coverage` 装进 `REAL_PACKAGES` 循环、
* 它的台账声明经 `applyDeclarations` 做过 `add-column` 之后才真的存在于物理表。
* 原来放在装活动**之前**播种,种到这些新列时 Postgres 直接报
* `column "cust_type" of relation "customer__profile" does not exist`——
* 主数据物化(平台基线列)在活动之前没错,但样例数据要用到**活动追加的列**时,
* 必须排在对应活动装完之后。按主键写,重复播种是覆盖不是堆叠;写正式版
* (不是草稿层),因为 A1 第一步查的就是正式版 —— 草稿只有本任务自己看得见。
*/
for (const row of MASTER_SAMPLE_ROWS) {
await container.ledger.systemWrite({
address: { domain: row.address.domain, object: row.address.object },
key: { ...row.key },
values: { ...row.values },
cause: { kind: 'governance', ref: 'seed:master-sample' },
});
}
log(` 样例客户已就位:${MASTER_SAMPLE_ROWS[1]?.values.email ?? ''}(样例 2 用它试老客户那条路)`);
}
+139
View File
@@ -175,6 +175,26 @@ export const MASTER_SAMPLE_ROWS = [
payment_terms: 'EXW · CNY 含税',
profile_tags: '项目制 · 按项目批量询价 · 认证要求严(AEC-Q200)',
status: 'active',
/*
* E04(关系覆盖与关键人经营)字段 —— 2026-08-20 补齐,让「客户已经跑过 E04、
* 找缺口」这条分支第一次能真被样例跑通(此前这几列全空,每轮都只能从零起草)。
* decision_chain 只覆盖了 bt-01(采购层),bt-02/bt-03 是刻意留的缺口。
*/
cust_type: 'Tier1 OEM',
coverage_score: 35,
decision_chain: {
nodes: [
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0142-01', covered: true },
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: '', covered: false },
{ node: 'bt-03', btLevel: 'bt-03', required: true, contactCode: '', covered: false },
],
},
interaction_timeline: {
entries: [
{ date: '2026-06-15', type: '邮件报价跟进', contactCode: 'CU-0142-01', note: 'MD14.1 项目 BOM 报价确认' },
{ date: '2026-07-20', type: '电话沟通', contactCode: 'CU-0142-01', note: '交期与付款方式确认' },
],
},
},
},
{
@@ -187,6 +207,13 @@ export const MASTER_SAMPLE_ROWS = [
title: '采购部',
// **认人的钥匙**:老客户样例的来信就从这个邮箱发,A1 第一步照它认得出
email: 'e.volkova@itelma.ru',
// E04 字段 —— 对应上面 decision_chain 的 bt-01 节点
role: '采购',
decision_node: 'bt-01',
influence: 'high',
relationship: 3,
comm_pref: 'Email',
key_person: true,
},
},
/*
@@ -205,4 +232,116 @@ export const MASTER_SAMPLE_ROWS = [
status: 'active',
},
},
/*
* **CU-0087** —— E04(关系覆盖与关键人经营)"关键人离职/调岗"样例专用夹具
* (2026-08-20 补):此前这条样例引用的 CU-0087 在种子数据里根本不存在,
* 是一处遗留 bug。这里种一位客户 + 两位联系人,decision_chain 的 bt-01
* (采购层)当下由陈刚覆盖 —— 样例说的正是"这个人要走了,这个节点要重新覆盖"。
*/
{
address: { domain: 'customer', object: 'profile' },
key: { customer_code: 'CU-0087' },
values: {
customer_code: 'CU-0087',
name: '深圳精密电子有限公司',
grade: 'B',
industry: '工业自动化',
region: '中国·深圳',
payment_terms: 'T/T 30 天',
profile_tags: '批量稳定 · 价格敏感',
status: 'active',
cust_type: '模组厂',
coverage_score: 55,
decision_chain: {
nodes: [
{ node: 'bt-01', btLevel: 'bt-01', required: true, contactCode: 'CU-0087-01', covered: true },
{ node: 'bt-02', btLevel: 'bt-02', required: true, contactCode: 'CU-0087-02', covered: true },
{ node: 'bt-03', btLevel: 'bt-03', required: false, contactCode: '', covered: false },
],
},
interaction_timeline: {
entries: [{ date: '2026-05-10', type: '现场拜访', contactCode: 'CU-0087-01', note: '年度框架协议签署' }],
},
},
},
{
address: { domain: 'customer', object: 'contact' },
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-01' },
values: {
customer_code: 'CU-0087',
contact_code: 'CU-0087-01',
name: 'Chen Gang',
title: '采购部长',
// **样例说的就是这个人**:「关键人离职/调岗」引用的正是这一位
email: 'chen.gang@sz-precision.com',
role: '采购',
decision_node: 'bt-01',
influence: 'high',
relationship: 4,
comm_pref: 'Email',
key_person: true,
},
},
{
address: { domain: 'customer', object: 'contact' },
key: { customer_code: 'CU-0087', contact_code: 'CU-0087-02' },
values: {
customer_code: 'CU-0087',
contact_code: 'CU-0087-02',
name: 'Li Wei',
title: '技术总监',
email: 'li.wei@sz-precision.com',
role: '技术决策人',
decision_node: 'bt-02',
influence: 'medium',
relationship: 3,
comm_pref: 'Email',
key_person: false,
},
},
/*
* I01(品牌形象与销售工具包管理)读的 L-01 宏观市场台账 —— 两条,呼应 I01 的两条
* 事件驱动样例(「进入俄罗斯市场」「E04 请求定向素材」),让「读市场信号 → 拟内容」
* 这条分支第一次有真数据可读,不必每轮都读空手。
*/
{
address: { domain: 'market', object: 'signal' },
key: { signal_id: 'MS-2026-0001' },
values: {
signal_id: 'MS-2026-0001',
signal_type: 'industry_trend',
title: '俄罗斯车规电子本土化替代加速',
content: '受供应链管制影响,俄罗斯本土车企与 Tier1 加快引入非欧美芯片供应商,车规级 AEC-Q200 认证成为准入门槛,采购决策周期缩短、更看重供货稳定性承诺。',
source: '行业报告',
signal_date: '2026-06-01',
market_region: '俄罗斯',
industry: '汽车电子',
chip_category: 'MCU',
importance: 4,
impact_dir: 'opportunity',
signal_status: 'evaluated',
version: '2026-Q2',
review_note: '',
},
},
{
address: { domain: 'market', object: 'signal' },
key: { signal_id: 'MS-2026-0002' },
values: {
signal_id: 'MS-2026-0002',
signal_type: 'customer_market',
title: 'Itelma 系客户新一轮车身控制器项目招标',
content: 'Itelma 及同类俄罗斯 Tier1 客户密集启动车身控制器新项目招标,价值传递素材需突出认证齐全度与批量交付能力,系统方案白皮书是决策链高层最看重的材料形式。',
source: '订阅',
signal_date: '2026-07-10',
market_region: '俄罗斯',
industry: '汽车电子',
chip_category: 'MCU',
importance: 5,
impact_dir: 'opportunity',
signal_status: 'evaluated',
version: '2026-Q2',
review_note: '',
},
},
] as const;
+80 -2
View File
@@ -169,6 +169,84 @@ const PLACEHOLDER_TOOL = {
connectionKeys: [] as string[],
};
/**
* E04 覆盖度重算 —— **纯工具**(活动私有作用域):决策链节点比对,同输入必同结论。
* 出参键名与台账列同名(`coverage_score`),模型拿回结果直接 write,不必改名。
*/
const COVERAGE_SCORE_TOOL = {
name: 'crm-relationship-coverage.recompute-score',
displayName: '关系覆盖度重算',
version: '1.1.0',
description:
'按决策链节点比对重算关系覆盖度:节点算已覆盖要同时满足有联系人对应/标了关键人/亲密度≥3。' +
'出参给覆盖率与缺口节点列表,权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。' +
'一次可查一批 —— 传 customers 数组,出参走 results(逐家客户各调一次会多截断一拍)。',
input: {
type: 'object',
properties: {
customers: {
type: 'array',
description: '批量(优先):[{customerCode, decisionNodes:[{node,btLevel,required}], contacts:[{contactCode,decisionNode,keyPerson,influence,relationship}]}]',
},
customerCode: { type: 'string', description: '单条查询' },
decisionNodes: { type: 'array', description: '单条:[{node, btLevel, required}]' },
contacts: { type: 'array', description: '单条:[{contactCode, decisionNode, keyPerson, influence, relationship}]' },
},
},
output: {
type: 'object',
properties: {
customerCode: { type: 'string' },
coverageScore: { type: 'integer' },
coveredNodes: { type: 'array' },
gapNodes: { type: 'array' },
results: { type: 'array', description: '批量查询的逐家结果:每项 {customerCode, coverageScore, coveredNodes, gapNodes}' },
},
},
effect: 'read' as const,
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
idempotent: true,
timeoutMs: 5_000,
retries: 0,
connectionKeys: [] as string[],
pure: true,
ownerActivityId: 'crm-relationship-coverage',
};
/**
* E04 关键人排序 —— **纯工具**(活动私有作用域):影响力 × 亲密度缺口,批量算。
* 出参键名与台账列同名(`priority`/`relationship_gap`/`rank_score`),模型照抄即可。
*/
const RANK_KEY_PERSONS_TOOL = {
name: 'crm-relationship-coverage.rank-key-persons',
displayName: '关键人经营优先级排序',
version: '1.0.0',
description:
'按影响力 × 亲密度缺口批量排序关键人 —— 传 contacts 数组,一次算完,不逐人临场拍。' +
'出参给每人的亲密度缺口、排序分与优先级档(high/medium/low)。',
input: {
type: 'object',
properties: {
contacts: { type: 'array', description: '[{contactCode, influence, relationship}]' },
},
},
output: {
type: 'object',
properties: {
ranked: { type: 'array', description: '[{contactCode, relationshipGap, rankScore, priority}],已按 rankScore 降序' },
counts: { type: 'object' },
},
},
effect: 'read' as const,
errors: [] as Array<{ code: string; retryable: boolean; message: string }>,
idempotent: true,
timeoutMs: 5_000,
retries: 0,
connectionKeys: [] as string[],
pure: true,
ownerActivityId: 'crm-relationship-coverage',
};
/** 白名单:逐槽位授权 —— 谁能调什么,治理台说了算,活动代码里看不到这张表 */
const ALLOWLIST: Readonly<Record<string, readonly string[]>> = {
// A1 已是真活动:白名单里摘掉占位工具 —— 它一在,模型就会拿它糊弄过去
@@ -211,7 +289,7 @@ export async function seedMvp(container: Container): Promise<void> {
log(`① LLM 实例已绑定:deepseek-v4-flash · deepseek-v4-pro${apiKey === undefined ? '(环境无密钥 —— 两条都是未绑定,去治理台补)' : ''}`);
/* ② 功能工具注册两审 + 逐槽位白名单 */
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL]) {
for (const contract of [HISTORY_TOOL, MAIL_TOOL, CLASSIFY_TOOL, PLACEHOLDER_TOOL, COVERAGE_SCORE_TOOL, RANK_KEY_PERSONS_TOOL]) {
const verdict = await container.toolbox.register({
contract,
samples: [
@@ -238,7 +316,7 @@ export async function seedMvp(container: Container): Promise<void> {
for (const [activityId, tools] of Object.entries(ALLOWLIST)) {
await container.toolbox.setAllowlist(activityId, tools);
}
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 2,逐活动授权)');
log('② 功能工具已注册并入白名单(读类 1 · 写类 1 · 纯工具 4,逐活动授权)');
/*
* ③ 活动装入 —— 两批:
+81
View File
@@ -207,6 +207,85 @@ const landingRate: ToolHandler = async ({ input }) => {
};
};
/**
* E04 覆盖度重算 —— **纯工具**:决策链节点比对,同输入必同结论。
*
* 节点算「已覆盖」要同时满足三条:该节点上有联系人对应(`decisionNode` 匹配)、
* 那个人标了 `keyPerson=true`、亲密度 `relationship>=3`。少一条都算空白 ——
* 认得这个人不等于覆盖了这个人。权重按 BT 层级(bt-03 高层 > bt-02 技术 > bt-01 采购)。
*
* **一次可查一批**(`customers` 数组):同一批候选客户逐一临场调,每调一次就多截断
* 一拍(工具调用后同拍其余动作作废)——一单 N 家候选就要 N 拍,批量传入只需一拍。
* 契约演进只增不破坏:仍收单条 `{customerCode,decisionNodes,contacts}` 走 legacy 路径,
* 批量给 `customers` 时优先。
*/
const BT_WEIGHT: Record<string, number> = { 'bt-01': 1, 'bt-02': 2, 'bt-03': 3 };
type DecisionNode = { node: string; btLevel: string; required: boolean };
type CoverageContact = { contactCode: string; decisionNode: string; keyPerson: boolean; influence: string; relationship: number };
type CustomerCoverageInput = { customerCode: string; decisionNodes: DecisionNode[]; contacts: CoverageContact[] };
const scoreOne = (customerCode: string, nodes: DecisionNode[], contacts: CoverageContact[]) => {
const required = nodes.filter((n) => n.required === true);
const coveredNodes: string[] = [];
const gapNodes: string[] = [];
let weightTotal = 0;
let weightCovered = 0;
for (const n of required) {
const weight = BT_WEIGHT[n.btLevel] ?? 1;
weightTotal += weight;
const covered = contacts.some((c) => c.decisionNode === n.node && c.keyPerson === true && c.relationship >= 3);
if (covered) {
coveredNodes.push(n.node);
weightCovered += weight;
} else {
gapNodes.push(n.node);
}
}
const coverageScore = weightTotal === 0 ? 0 : Math.round((weightCovered / weightTotal) * 100);
return { customerCode, coverageScore, coveredNodes, gapNodes };
};
const recomputeScore: ToolHandler = async ({ input }) => {
const batch = input['customers'];
if (Array.isArray(batch)) {
const results = (batch as CustomerCoverageInput[]).map((c) =>
scoreOne(c.customerCode, Array.isArray(c.decisionNodes) ? c.decisionNodes : [], Array.isArray(c.contacts) ? c.contacts : []),
);
return { ok: true, output: { results } };
}
const nodes = Array.isArray(input['decisionNodes']) ? (input['decisionNodes'] as DecisionNode[]) : [];
const contacts = Array.isArray(input['contacts']) ? (input['contacts'] as CoverageContact[]) : [];
return { ok: true, output: scoreOne(String(input['customerCode'] ?? ''), nodes, contacts) };
};
/**
* E04 关键人排序 —— **纯工具**:影响力 × 亲密度缺口,批量算,不逐人临场拍。
*
* `relationshipGap = 5 - relationship`(亲密度越低缺口越大);
* `rankScore = 影响力权重 × relationshipGap`;阈值 `>=9→high, >=4→medium, else→low`
* (业务可调常量,不是这一单的临场判断)。
*/
const INFLUENCE_WEIGHT: Record<string, number> = { high: 3, medium: 2, low: 1 };
const rankKeyPersons: ToolHandler = async ({ input }) => {
const contacts = Array.isArray(input['contacts'])
? (input['contacts'] as Array<{ contactCode: string; influence: string; relationship: number }>)
: [];
const ranked = contacts
.map((c) => {
const weight = INFLUENCE_WEIGHT[c.influence] ?? 1;
const relationshipGap = Math.max(0, 5 - Number(c.relationship ?? 0));
const rankScore = weight * relationshipGap;
const priority = rankScore >= 9 ? 'high' : rankScore >= 4 ? 'medium' : 'low';
return { contactCode: c.contactCode, relationshipGap, rankScore, priority };
})
.sort((a, b) => b.rankScore - a.rankScore);
const counts = { high: 0, medium: 0, low: 0 } as Record<string, number>;
for (const r of ranked) counts[r.priority] = (counts[r.priority] ?? 0) + 1;
return { ok: true, output: { ranked, counts } };
};
/** 出站发送 —— 写类:过程中登记为动作意向,放行后由系统执行落地(OS 7.3) */
const mailSend: ToolHandler = async ({ input, connection }) => {
// 运行时只拿得到申报过的连接目标;真发信由 channel 的出站面承担
@@ -272,4 +351,6 @@ export const FIRST_PARTY_TOOLS: ReadonlyArray<readonly [string, ToolHandler]> =
['quote-archive.landing-rate', landingRate],
['mail.send', mailSend],
['slot.placeholder', slotPlaceholder],
['crm-relationship-coverage.recompute-score', recomputeScore],
['crm-relationship-coverage.rank-key-persons', rankKeyPersons],
];
+8 -4
View File
@@ -9,6 +9,10 @@
* 就能解;为一件读表的活拉进一整个电子表格库,是拿依赖换五十行代码。
*
* 这一份是**纯计算**:进字节、出文本,不碰库不碰网,所以能逐条测。
*
* 其中 `readZip` / `sheetToRows` 这几件**对外**:资料文件那一侧(`file-doc.ts`)
* 要把造好的 docx / xlsx 再读回来渲染 —— 读的这一套只该有一份实现,
* 两处各写一份迟早在某个边角上分叉(而分叉的症状是「下载能开、界面渲不出」)。
*/
import { inflateRawSync } from 'node:zlib';
@@ -56,7 +60,7 @@ function cap(text: string, how: string): Extracted {
* 只支持 store(0)与 deflate(8)两种压缩法 —— xlsx 出厂就这两种。
* 认不出的条目跳过而不是抛:一份表里混了一条读不了的条目,不该让整份表读不出来。
*/
function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
export function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const files = new Map<string, Uint8Array>();
@@ -104,7 +108,7 @@ function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
}
/** XML 实体还原 —— 只有这五个是 XML 预定义的 */
function unescapeXml(s: string): string {
export function unescapeXml(s: string): string {
return s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
@@ -115,7 +119,7 @@ function unescapeXml(s: string): string {
}
/** 共享字符串表:`<si>` 一条一个字符串,里面可能被拆成多个 `<t>`(富文本) */
function sharedStrings(xml: string): string[] {
export function sharedStrings(xml: string): string[] {
return [...xml.matchAll(/<si>([\s\S]*?)<\/si>/g)].map((m) =>
[...(m[1] ?? '').matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((t) => unescapeXml(t[1] ?? '')).join(''),
);
@@ -135,7 +139,7 @@ function columnOf(ref: string): number {
* 单元格三种取值:`t="s"` 查共享串表、`t="inlineStr"` 取内联 `<t>`、其余取 `<v>` 原样
* (数字、日期序列号都在这一类)。日期不转换:转错了比不转更糟,原值至少可以追。
*/
function sheetToRows(xml: string, shared: readonly string[]): string[][] {
export function sheetToRows(xml: string, shared: readonly string[]): string[][] {
const rows: string[][] = [];
for (const rowMatch of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
const cells: string[] = [];
+68 -1
View File
@@ -37,6 +37,7 @@ import type { TriggerService } from '@modules/trigger';
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
import type { StaffId, TaskId } from '@contracts';
import { cardIdSchema, staffIdSchema } from '@contracts';
import { archiveAssetFiles } from '../asset-archive.ts';
import { createAgentRunner } from '../agent-runner.ts';
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
import type { Clock, IdGenerator } from '../runtime.ts';
@@ -673,6 +674,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const owner = task === undefined ? undefined : await deps.org.getStaff(task.ownerId).catch(() => undefined);
const ownerLabel = owner === undefined ? '' : `${owner.name} · ${owner.position.name}`;
const origin = task?.origin === 'testbed' ? '试跑起单' : '通道入站';
/*
* **这一单的任务号** —— 工单模版卡头「关联任务」那一格(E04 目标件 §6.2 点名的
* 共用动态位)。与归属签同理:任务号是基座的事实,活动写不出也不该写;
* 全长 UUID 如实给,摆多长由模版自己的样式定(截断是排版,不是数据)。
*/
const taskRef = task === undefined ? '' : String(taskId);
/*
* **这一单算出来要走哪一口** —— 报告上那张分支判定表要标出命中的是哪一行。
*
@@ -703,6 +710,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
'case:openedAt': openedAt,
'case:origin': origin,
'case:owner': ownerLabel,
'case:taskId': taskRef,
...route,
...own,
}),
@@ -797,7 +805,22 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const declared = manifest.declarations.ledger;
// 工具调用是**留痕里的事实**:判据要说「亲查过行情」,得有一边不是活动自报的
const trail = await deps.memory.window(taskId, 400);
return {
/*
* **开案时刻要真的在 Agent 的作用域里**(2026-08-22,E04 实测逼出来的):
* 说明书写着「round_ref = case:openedAt 按 YYYYMMDDHHmmss 格式化(引擎注入的
* 开案时刻)」,而这个键从前只注进**视图渲染**那一侧,Agent 的任务视图里没有 ——
* 模型只能自己编;正式表里一旦有别单的案号,它就照抄(A2 治过的「从台账借号」
* 同款),测试台里所有单于是共用同一个号,跨单主键相撞、上一单的正式行渗进
* 下一单的读面。给全到秒(视图那份只到分,是给人看的;这份是给铸主键用的)。
*/
const two = (n: number): string => String(n).padStart(2, '0');
const opened = new Date(task.startedAt);
const openedAt = Number.isNaN(opened.getTime())
? ''
: `${opened.getFullYear()}-${two(opened.getMonth() + 1)}-${two(opened.getDate())} ` +
`${two(opened.getHours())}:${two(opened.getMinutes())}:${two(opened.getSeconds())}`;
const enriched: Record<string, unknown> = {
'case:openedAt': openedAt,
...scope,
...rowReadings(mine, declared),
...rowKeyList(mine, declared),
@@ -806,6 +829,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
...toolCallReadings(trail, manifest.declarations.tools),
...mine,
};
return enriched;
},
/**
* 本活动声明过的台账对象 —— 清单 `declarations.ledger` 就是这份名单。
@@ -955,6 +979,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
});
return { id: listener.id };
},
/** 立监听的授权面 —— 活动清单声明过的那几种(见 port.ts 的注释) */
async declared(taskId: TaskId) {
const { activityId } = await taskContext.of(taskId);
const manifest = await deps.registry.manifestOf(activityId);
return manifest.declarations.listeners.map((l) => ({ eventType: l.eventType, keyFilter: l.keyFilter }));
},
};
/**
@@ -2094,6 +2124,43 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
* 在一张验收单上改几十行是另一件事。
*/
await applyRevisions(input.taskId);
/*
* **产出的资料文件在这儿冻住** —— 修订刚落回草稿层、合并还没发生:
* 存下来的正是他签字那一刻的样子(与工单的 `finalHtml` 快照同一个道理)。
*
* 为什么非存不可,而不是回头照台账现造:资料常落在**主数据**上,
* 而试跑单放行时主数据只留在活动缓存、不落正式表 —— 不存这一份,
* 试跑单一放行,人在工作成果里就什么都点不开了。正式单则是另一头的理由:
* 台账后来被下一单改了,归档件不该跟着改口。
* 存不下来不拦放行(资料是成果的一份,不是放行的前提):记一条痕,人看得见。
*/
try {
const manifest = await deps.registry.manifestOf((await taskContext.of(input.taskId)).activityId);
const saved = await archiveAssetFiles({
db,
taskId: input.taskId,
ids,
declarations: manifest.declarations.ledger,
rowsOfAddress: async (address) =>
(
await deps.ledger.taskView(input.taskId, { address, where: {}, orderBy: [], limit: 500, offset: 0 })
).rows as Array<Record<string, unknown>>,
});
if (saved > 0) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'ledger-write' as never,
title: `产出的资料已存档:${saved} 份(工作成果里点得开)`,
detail: { assetFiles: saved },
});
}
} catch (error) {
await memoryRecorder.append({
taskId: input.taskId,
kind: 'result',
title: `资料存档没做成:${error instanceof Error ? error.message : String(error)}`,
});
}
const outcome = await deps.release.land({
taskId: input.taskId,
ownerId: input.ownerId,
+101
View File
@@ -0,0 +1,101 @@
/**
* 最小 ZIP 打包器 —— docx / xlsx 就是 ZIP 包着几份 XML,写它才写得出真文件。
*
* 只做 **store(不压缩,method 0)**:合法的 ZIP,Word / Excel / macOS 归档工具都认。
* 为什么不压:压缩要 deflate + 一套长度对账,而这些 XML 至多几十 KB ——
* 省下来的那几 KB 换一处能错的地方,不划算。将来真要压,把 `method` 与数据段一起换即可。
*
* 读的那一侧在 `upload-extract.ts`(`readZip`),两边合起来能自证:
* 造出来的包读得回来,读回来的内容与进去的一模一样。
*/
/** CRC-32(IEEE 多项式 0xEDB88320)—— ZIP 每条目都要校验和,写错了解压工具会报损坏 */
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) c = (c & 1) === 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
export function crc32Of(bytes: Uint8Array): number {
let c = 0xffffffff;
for (const b of bytes) c = (CRC_TABLE[(c ^ b) & 0xff] ?? 0) ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
type Entry = readonly [name: string, bytes: Uint8Array];
/**
* 打包。条目顺序即包内顺序 —— `[Content_Types].xml` 习惯放第一条(不少读取器认这个)。
*
* 时间戳一律写 1980-01-01(DOS 时间的下限):同样的入参出同样的字节,
* 这样「文件变没变」就是内容的事,不掺一个每次都不同的时间 —— 也让测试断得住。
*/
export function zipStore(entries: readonly Entry[]): Uint8Array {
const enc = new TextEncoder();
const locals: Uint8Array[] = [];
const centrals: Uint8Array[] = [];
let offset = 0;
for (const [name, data] of entries) {
const nameBytes = enc.encode(name);
const crc = crc32Of(data);
const local = new Uint8Array(30 + nameBytes.length + data.length);
const lv = new DataView(local.buffer);
lv.setUint32(0, 0x04034b50, true); // 本地头签名
lv.setUint16(4, 20, true); // 需要 2.0 才能解
lv.setUint16(6, 0x0800, true); // 位标志:文件名是 UTF-8
lv.setUint16(8, 0, true); // method 0 = store
lv.setUint16(10, 0, true); // 时:0
lv.setUint16(12, 33, true); // 日:1980-01-01
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true); // 压缩后大小 = 原大小(store)
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true); // 无 extra
local.set(nameBytes, 30);
local.set(data, 30 + nameBytes.length);
locals.push(local);
const central = new Uint8Array(46 + nameBytes.length);
const cv = new DataView(central.buffer);
cv.setUint32(0, 0x02014b50, true); // 中央目录签名
cv.setUint16(4, 20, true); // 造它的版本
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, 0, true);
cv.setUint16(14, 33, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true); // 本地头在包里的偏移
central.set(nameBytes, 46);
centrals.push(central);
offset += local.length;
}
const centralSize = centrals.reduce((n, c) => n + c.length, 0);
const eocd = new Uint8Array(22);
const ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, entries.length, true); // 本卷条目数
ev.setUint16(10, entries.length, true); // 总条目数
ev.setUint32(12, centralSize, true);
ev.setUint32(16, offset, true); // 中央目录起点
const total = offset + centralSize + eocd.length;
const out = new Uint8Array(total);
let p = 0;
for (const part of [...locals, ...centrals, eocd]) {
out.set(part, p);
p += part.length;
}
return out;
}