Archived
资料文件真产出来了;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:
+383
-36
@@ -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 秒)那段时间对话区一动不动,
|
||||
|
||||
Reference in New Issue
Block a user