Archived
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
1741 lines
86 KiB
TypeScript
1741 lines
86 KiB
TypeScript
/**
|
|
* 工作台路由 —— 五要素是**同一份过程事实的五种投影**(编码规范 §3;语义出处 OS 2.1)。
|
|
*
|
|
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
|
*/
|
|
import { sql } from 'drizzle-orm';
|
|
import type { Hono } from 'hono';
|
|
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 { assetRowsOf, 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';
|
|
|
|
/** drizzle 的 execute 出参在不同驱动下形状不一 —— 收在一处判 */
|
|
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[]) : [];
|
|
}
|
|
import { staffOf } from '../http-env.ts';
|
|
import type { AppEnv } from '../http-env.ts';
|
|
import { coerceToColumn, missingReadings, rowLabel } from '../wiring/engines.ts';
|
|
import { orderChat } from '../chat-order.ts';
|
|
|
|
|
|
/** 一张表收上来的样子:`{ 台账列名: 值 }`,一行一条 —— 主键列在里面,但只用来认行 */
|
|
type EditedRow = Readonly<Record<string, string>>;
|
|
|
|
/** 写回的收成:改了几行、新增几行、撤了几行、哪几格归不了型 */
|
|
type WriteBack = {
|
|
rows: number;
|
|
added: number;
|
|
/**
|
|
* **人新增的是哪几行**(主键,2026-08-24 柴浩实测补):从前只有个数「新增 1 行」——
|
|
* 模型看不见这一屏,只看得见轨迹;不点名,它就守着自己记忆里的「识别出 1 家」
|
|
* 把人加的那家从头无视到尾(计划/回执/收口一行都没它)。撤行早就点名了
|
|
* (「撤掉的是人定的,别再写回去」),加行是同一条道理的另一半。
|
|
*/
|
|
addedKeys: string[];
|
|
dropped: string[];
|
|
bad: string[];
|
|
/**
|
|
* **认不出是哪一行、因而没落下的那几行**(2026-08-24 用户实测:
|
|
* 「用编辑新增了一个客户,推进到下一步直接就把我手动添加的这一行遗漏掉了」)。
|
|
*
|
|
* 从前这里是一句 `continue` —— 行悄悄没了,而接口照旧回 `{ok:true}`:
|
|
* 人看到「保存成功」,东西却不在。**保存说成功,就得真的都存下了**;
|
|
* 存不下的必须点名说出来(是哪张表、缺哪几列钥匙),让他补得上。
|
|
*/
|
|
unkeyed: Array<{ address: string; keys: string[]; hint: string }>;
|
|
};
|
|
|
|
const nothing = (): WriteBack => ({ rows: 0, added: 0, addedKeys: [], dropped: [], bad: [], unkeyed: [] });
|
|
const merge = (into: WriteBack, one: WriteBack): void => {
|
|
into.rows += one.rows;
|
|
into.added += one.added;
|
|
into.addedKeys.push(...one.addedKeys);
|
|
into.dropped.push(...one.dropped);
|
|
into.bad.push(...one.bad);
|
|
into.unkeyed.push(...one.unkeyed);
|
|
};
|
|
|
|
/**
|
|
* **把人改过的那几行落进本单草稿层** —— 两条路(件上按保存 / 卡上按提交)共用这一段。
|
|
*
|
|
* 主键列取台账声明的主键 —— 人改主键那一格等于换了一行,所以**主键不参与写回**:
|
|
* 用它认行,不用它改行(改错一个字符就会凭空多出一行,踩过)。
|
|
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
|
*/
|
|
/**
|
|
* **人在件上改的/加的这一行,主键怎么凑齐** —— 凑不齐就落不下去,而屏上那一行还在
|
|
* (界面说加上了,台账里没有 —— 最坏的一类:它不报错)。三类主键三种凑法:
|
|
*
|
|
* ① 件上摆了、人也填了 → 就用他填的(主键只认行、不改行);
|
|
* ② **件上压根没摆的**(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>>;
|
|
/** 声明了 `auto` 的主键列 —— 见下面那条「空着的自增列不许从别的行借号」 */
|
|
autos?: readonly 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;
|
|
|
|
/*
|
|
* **空着的自增列不是身份线索,是「给我续个号」**(2026-08-24 确认屏补录联系人逼出):
|
|
* 人加的新行联系人编码本来就该空着(自增列不问人),可 findKnownRow 拿着只有
|
|
* customer_code 的半把钥匙,会把这一行错配到该客户**已有的草稿联系人行**上 ——
|
|
* 编码从那行借来,「新增」当场变「覆盖」:模型刚建的那位被人加的这位顶掉,
|
|
* 痕还写着「改了 1 行」。声明了 auto 的列,件上摆着却空着 → 一律不许从别的行
|
|
* 借值,留给续号那条路(writeEditedRows 的 mint)。
|
|
*/
|
|
const barred = new Set((input.autos ?? []).filter((k) => k in cells && (cells[k] ?? '').trim() === ''));
|
|
const known = barred.size > 0 ? undefined : 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 (barred.has(k)) continue;
|
|
if (known === undefined && isSeq(k)) continue;
|
|
/*
|
|
* **件上摆着、他却空着的那一列,不许替他编一个值**(2026-08-24 用户实测的第二面)。
|
|
*
|
|
* 「全表一致」这条路是给**件上压根没摆**的主键用的(如案号 `round_ref`:
|
|
* 一单一号,全表当然一致,基座补它天经地义)。可如果那一列就在件上、人能填、
|
|
* 他没填 —— 补出来的值是**替他决定这一行是谁**:表里恰好只有一行时,
|
|
* 新加的那行会拿到老行的客户编码,**当场把老行覆盖掉**(屏上两行,库里一行)。
|
|
* 序号型主键 2026-08-23 已经因为同一个原因单独绕开过 —— 那是这条规律的一个特例,
|
|
* 现在按「他填得了吗」统一判:填得了就必须他填,基座只补他碰不到的那几列。
|
|
*/
|
|
if (known === undefined && k in cells) 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,
|
|
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; type: string; primaryKey: boolean; auto?: boolean | undefined }> },
|
|
lines: readonly EditedRow[],
|
|
): Promise<WriteBack> {
|
|
const { domain, object } = declared;
|
|
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
|
const out = nothing();
|
|
// 本单草稿行 —— 补主键用(件上没摆全的那几列按它认)
|
|
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
|
/*
|
|
* **「真改了吗」要对着叠加后的行比,不能只对草稿**(2026-08-24 落库预览逼出来的):
|
|
* 预览回显的是正式现值 + 草稿的叠加 —— 人一格没动按下保存,回传里带着的是回显值;
|
|
* 只对草稿比的话,草稿里没有的那几列(如 Itelma 既有的客户类型)全算「改了」,
|
|
* 痕上写「改了 2 行」而人一个字没动(日志撒谎),还平白多出一笔草稿。
|
|
* 「这一行是不是新增」同理:正式表里有、草稿里没有的行,不是新增。
|
|
*/
|
|
const page = await container.ledger
|
|
.taskView(taskId, { address: { domain, object }, where: {}, orderBy: [], limit: 500, offset: 0 })
|
|
.catch(() => ({ rows: [] as Array<Record<string, unknown>> }));
|
|
const types = Object.fromEntries(declared.fields.map((f) => [f.name, f.type]));
|
|
for (const cells of lines) {
|
|
/*
|
|
* 主键**只用来认行,不参与改行**:人改了主键那一格等于换了一行,
|
|
* 落下去就是凭空多一条(实测过同一颗料在表里成了两行)。
|
|
* 表里没摆全主键的(如单号那一列不在件上),从草稿现有的行里补 —— 认不出就跳过。
|
|
* 凑主键这一段抽成了纯函数 `resolveRowKey`(有测):三类主键三种凑法,
|
|
* 尤其序号型那一手不补,人在件上加的行会被静默丢掉。
|
|
*/
|
|
const key = resolveRowKey({
|
|
keys,
|
|
cells,
|
|
drafts,
|
|
types,
|
|
autos: declared.fields.filter((f) => f.primaryKey && f.auto === true).map((f) => f.name),
|
|
});
|
|
/*
|
|
* **人手改的值与 AI 写的值过同一道归型门**(`coerceToColumn`)。
|
|
*
|
|
* 不过这道门的代价实测过:夹具往「目标价」那一格填了「客户已确认」,
|
|
* 基座原样落进草稿,到 `releaseWrite` 才被 PG 掀桌
|
|
* (`invalid input syntax for type double precision`)—— 而那是**人签完字之后**,
|
|
* 整单落 `blocked`。最坏的失败点就是这里:前面全对,最后一步炸。
|
|
* 归得了就归、空值不写、归不了**跳过那一格**(别的格照落):
|
|
* 人填错一格不该把他改对的另外八格一起丢掉。
|
|
*/
|
|
const patch: Record<string, unknown> = {};
|
|
for (const [name, value] of Object.entries(cells)) {
|
|
if (keys.includes(name)) continue;
|
|
const type = declared.fields.find((f) => f.name === name)?.type ?? 'text';
|
|
const coerced = coerceToColumn(value, type);
|
|
if (coerced.kind === 'value') patch[name] = coerced.value;
|
|
else if (coerced.kind === 'reject') out.bad.push(`${name}=${value}`);
|
|
}
|
|
if (key === undefined) {
|
|
const filled = Object.entries(cells).filter(([, v]) => v.trim() !== '');
|
|
/*
|
|
* **自增主键空着不是「他没填」,是「不用他填」**(2026-08-24 用户在台账更新确认
|
|
* 屏上补录联系人逼出来的):声明标了 `auto` 的主键(联系人编码)本就不该问人 ——
|
|
* 录入卡那条路 2026-08-02 就立了这条规矩,件上「加一行」是同一件事的另一个入口。
|
|
* 其余主键他都填了、缺的恰好全是 auto 列 → 由基座按声明的续号规则铸
|
|
* (同组其余主键值-两位序号,序号数正式版 + 本单草稿,与录入卡同一段 autoKeys)。
|
|
*/
|
|
const autoCols = keys.filter((k) => declared.fields.find((f) => f.name === k)?.auto === true);
|
|
const blankKeys = keys.filter((k) => (cells[k] ?? '').trim() === '');
|
|
const mintable =
|
|
filled.length > 0 &&
|
|
autoCols.length > 0 &&
|
|
blankKeys.length > 0 &&
|
|
blankKeys.every((k) => autoCols.includes(k)) &&
|
|
keys.filter((k) => !autoCols.includes(k)).every((k) => (cells[k] ?? '').trim() !== '');
|
|
if (mintable) {
|
|
const given = Object.fromEntries(keys.filter((k) => !autoCols.includes(k)).map((k) => [k, (cells[k] ?? '').trim()]));
|
|
const minted = await autoKeys(container, taskId, declared as LedgerDeclaration, given);
|
|
const newKey = { ...given, ...minted };
|
|
if (keys.every((k) => (newKey[k] ?? '') !== '')) {
|
|
await container.ledger.draftWrite({ taskId, address: { domain, object }, key: newKey, patch });
|
|
out.added += 1;
|
|
out.addedKeys.push(rowLabel(newKey, declared.caseKey));
|
|
continue;
|
|
}
|
|
}
|
|
/*
|
|
* **认不出这一行,就得说出来** —— 从前这里是一句 `continue`:人在件上加了一行、
|
|
* 按了保存、接口回 `{ok:true}`,而那一行根本没落下(2026-08-24 用户实测)。
|
|
* 键凑不齐只有一种情形:主键那几格他没填(件上摆着、标着必填,但空着)。
|
|
* 空行不算 —— 那是「加一行」点出来还没动的骨架,人自己也知道它是空的。
|
|
*/
|
|
if (filled.length > 0) {
|
|
/*
|
|
* **只点名他填得了的那几列** —— 件上摆着、却空着的主键格。
|
|
* 压根不在件上的主键(如案号 `round_ref`,由基座按全表一致补)不许出现在这句话里:
|
|
* 那是基座的活,写进去等于把人指向一扇他打不开的门(拒回指错方向比不指更糟)。
|
|
*/
|
|
// 自增列不点名 —— 人填不了它(拒回指向打不开的门比不指更糟)
|
|
const missing = keys.filter(
|
|
(k) => k in cells && (cells[k] ?? '').trim() === '' && declared.fields.find((f) => f.name === k)?.auto !== true,
|
|
);
|
|
// 人读名优先(件上摆的是「客户编号」,不是 customer_code)—— 取不到就退列名
|
|
const label = (name: string): string => {
|
|
const found = declared.fields.find((f) => f.name === name) as { label?: string } | undefined;
|
|
return found?.label ?? name;
|
|
};
|
|
out.unkeyed.push({
|
|
address: `${domain}.${object}`,
|
|
keys: missing,
|
|
hint:
|
|
missing.length > 0
|
|
? `这一行没能落下 —— ${missing.map(label).join('、')}是它的钥匙,填上才存得住`
|
|
: '这一行没能落下 —— 认不出它是哪一行(主键这几格件上没摆出来),请报给开发',
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const was = page.rows.find((r) => keys.every((k) => String((r as Record<string, unknown>)[k] ?? '') === key[k]));
|
|
if (!rowChanged(was as Record<string, unknown> | undefined, patch)) continue;
|
|
await container.ledger.draftWrite({ taskId, address: { domain, object }, key, patch });
|
|
if (was === undefined) {
|
|
out.added += 1;
|
|
out.addedKeys.push(rowLabel(key, declared.caseKey));
|
|
} else out.rows += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* **把人在件上删掉的那几行撤出本单草稿层**。
|
|
*
|
|
* 撤的只是这一单还没过门的那一笔(`draftDrop`):正式版一个字都动不了,也不抛事件 ——
|
|
* 与 Agent 的 `drop` 同一条路子。人手删行是**兜 AI 的错**的一手:
|
|
* 客户点名本轮跳过的料被写进了有效清单,只给他改格子是改不掉的
|
|
* (改数又与另一个数打架 —— 实测那一单 13 次拒回、234k token 才熔断)。
|
|
*
|
|
* 主键摆不全的一律不撤:**宁可少撤一行,不要撤错一行**。
|
|
*/
|
|
async function dropEditedRows(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
declared: { domain: string; object: string; caseKey?: string | undefined; fields: ReadonlyArray<{ name: string; primaryKey: boolean }> },
|
|
lines: readonly EditedRow[],
|
|
): Promise<WriteBack> {
|
|
const { domain, object } = declared;
|
|
const keys = declared.fields.filter((f) => f.primaryKey).map((f) => f.name);
|
|
const out = nothing();
|
|
const drafts = (await container.ledger.taskDrafts(taskId)).filter((d) => d.domain === domain && d.object === object);
|
|
|
|
for (const cells of lines) {
|
|
// 按件上给出的那几列主键找本单的行 —— 找不到(或不止一行)就不动
|
|
const hit = drafts.filter((d) =>
|
|
keys.every((k) => cells[k] === undefined || cells[k]?.trim() === '' || String(d.key[k] ?? '') === cells[k]),
|
|
);
|
|
const one = hit.length === 1 ? hit[0] : undefined;
|
|
if (one === undefined) continue;
|
|
const gone = await container.ledger.draftDrop({ taskId, address: { domain, object }, key: one.key });
|
|
if (gone) out.dropped.push(rowLabel(one.key, declared.caseKey));
|
|
}
|
|
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]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* **这一行真改了吗** —— 件上按一次保存,整张表的格子都回传上来(它不知道人动了哪一格)。
|
|
*
|
|
* 照单全写会留一条「就地修订成果:4 行」的痕,而人只改了一格 —— 日志是给人看的,
|
|
* 那句话就成了假的(用户实测点出来的:改一格,日志说改了四行)。
|
|
* 比的是**摊平后的整行**:主键在里面也无妨(它按定义不参与改行)。
|
|
* 值一律转字符串再比 —— 台账里躺的是数字 30000,人手填回来的是 "30000",
|
|
* 归型之后两者相等,可类型不同;拿类型去比会把「没改」判成「改了」。
|
|
*/
|
|
export function rowChanged(
|
|
existing: Readonly<Record<string, unknown>> | undefined,
|
|
patch: Readonly<Record<string, unknown>>,
|
|
): boolean {
|
|
if (existing === undefined) return true; // 台账里还没有这一行 —— 那是新增,当然算改
|
|
return Object.entries(patch).some(([k, v]) => String(existing[k] ?? '') !== String(v ?? ''));
|
|
}
|
|
|
|
/**
|
|
* **自增主键由基座生成**(2026-08-02 用户:「联系人ID自增啊,不用填」)。
|
|
*
|
|
* 声明里标了 `auto` 的主键列不进卡面(见 `entryGroup`),值在写入这一刻补上:
|
|
* **取同组其余主键的值作前缀,后面跟两位序号** —— `CU-9001-01`、`-02`……
|
|
* 序号数的是同组已有的行(正式版 + 本单草稿),同一单里加两位联系人也不撞。
|
|
* 没有其余主键(它自己就是唯一那把键)时退回纯序号。
|
|
*
|
|
* 为什么不让人填:他哪知道这家客户已经有几位联系人。让模型编更糟 ——
|
|
* 编重了就是覆盖别人那一行,而主数据覆盖错了后面每一单都跟着错。
|
|
*/
|
|
async function autoKeys(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
declared: LedgerDeclaration | undefined,
|
|
given: Readonly<Record<string, string>>,
|
|
): Promise<Record<string, string>> {
|
|
const auto = (declared?.fields ?? []).filter((f) => f.primaryKey && f.auto === true).map((f) => f.name);
|
|
if (declared === undefined || auto.length === 0) return {};
|
|
const others = declared.fields.filter((f) => f.primaryKey && f.auto !== true).map((f) => f.name);
|
|
const prefix = others
|
|
.map((k) => given[k] ?? '')
|
|
.filter((v) => v !== '')
|
|
.join('-');
|
|
/*
|
|
* 同组已有几行 —— 正式版与本单草稿都要数。只数正式版的话,
|
|
* 同一单里连加两位联系人会拿到同一个号,后一位把前一位覆盖掉。
|
|
*/
|
|
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>> }));
|
|
const same = page.rows.filter((r) => others.every((k) => String(r[k] ?? '') === (given[k] ?? '')));
|
|
const out: Record<string, string> = {};
|
|
for (const [i, name] of auto.entries()) {
|
|
const seq = String(same.length + 1 + i).padStart(2, '0');
|
|
out[name] = prefix === '' ? seq : `${prefix}-${seq}`;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* **办结之后:这一签下去,世界上发生了什么**(2026-08-02 用户定)。
|
|
*
|
|
* 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的换成了另一件事 ——
|
|
* 落了几行、进了哪几张表、转去哪儿了。从前两件事共用一屏判据表,
|
|
* 签完再回看,那一摞对勾说的还是签之前的话。
|
|
*
|
|
* 事实取自放行那一刻的**结构化留痕**(`ledger-write` / `release-verdict` 的 detail),
|
|
* 不是把日志那句话再解析一遍 —— 措辞一改解析就失灵,而没人会发现。
|
|
*/
|
|
async function landingOf(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
activityId: string,
|
|
): Promise<
|
|
| { rows: number; simulated: number; tables: Array<{ label: string; rows: number }>; ports: string[]; carried: string[] }
|
|
| undefined
|
|
> {
|
|
const entries = await container.memory.history(taskId);
|
|
const wrote = entries.filter((e) => e.kind === 'ledger-write' && e.detail?.['tables'] !== undefined).at(-1);
|
|
const fired = entries.filter((e) => e.kind === 'release-verdict' && e.detail?.['ports'] !== undefined).at(-1);
|
|
if (wrote === undefined && fired === undefined) return undefined;
|
|
|
|
const objects = await container.ledger.listObjects().catch(() => []);
|
|
const manifest = await container.registry.manifestOf(activityId).catch(() => undefined);
|
|
/*
|
|
* **按交付物点名,不按表点名**(2026-08-02 用户当场指出:「料号分类表和受理结论
|
|
* 不都是包含在询价受理记录的结构里的吗,为啥单独陈列?」)。
|
|
*
|
|
* 一份成果常常不止一张表:A1 交出的是一份询价受理记录,而它由受理记录、
|
|
* 料号分类表、受理结论三张表承载 —— 人拿到的是**一份**,不是三份。
|
|
* 清单里 `ledgers` 说了哪几张表归哪份成果,这里照它并;
|
|
* 不归任何一份成果的(客户档案这类主数据)按自己的表名摆。
|
|
*/
|
|
const owner = new Map<string, string>();
|
|
for (const d of manifest?.contract.requiredDeliverables ?? []) {
|
|
for (const at of d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger])) owner.set(at, d.label);
|
|
}
|
|
const merged = new Map<string, number>();
|
|
for (const raw of Array.isArray(wrote?.detail?.['tables']) ? (wrote.detail['tables'] as unknown[]) : []) {
|
|
const t = (typeof raw === 'object' && raw !== null ? raw : {}) as { address?: unknown; rows?: unknown };
|
|
const address = String(t.address ?? '');
|
|
const [domain, object] = address.split('.');
|
|
// 摆的是人读名:先看它归哪份成果,再退回表的登记名,最后才退回地址
|
|
const label = owner.get(address) ?? objects.find((o) => o.domain === domain && o.object === object)?.label ?? address;
|
|
if (label === '') continue;
|
|
merged.set(label, (merged.get(label) ?? 0) + Number(t.rows ?? 0));
|
|
}
|
|
const tables = [...merged].map(([label, rows]) => ({ label, rows }));
|
|
|
|
/* 出口也摆人读名:契约里每个口都写了 `description`,那才是给人看的那一句 */
|
|
const named = new Map<string, string>();
|
|
for (const p of manifest?.contract.output.mandatory ?? []) named.set(p.name, p.description ?? p.name);
|
|
for (const g of manifest?.contract.output.selective ?? []) {
|
|
for (const p of g.ports) named.set(p.name, p.description ?? p.name);
|
|
}
|
|
const ports = (Array.isArray(fired?.detail?.['ports']) ? (fired.detail['ports'] as unknown[]) : [])
|
|
.map((p) => named.get(String(p)) ?? String(p))
|
|
.filter((p) => p !== '');
|
|
/*
|
|
* **带过去了什么**(2026-08-02 用户定:「流向下一个活动带了什么数据」)。
|
|
*
|
|
* 发射的载荷就是**已落地的那些事实**(见 `release-service` 的 `landedFacts`),
|
|
* 摆的是列的中文名 —— 下游活动读到的正是这几个字段。
|
|
* 只摆名字不摆值:值在成果件里,这一屏答的是「带了哪些东西过去」。
|
|
*/
|
|
const colLabel = new Map<string, string>();
|
|
for (const d of manifest?.declarations.ledger ?? []) {
|
|
for (const f of d.fields) if (f.label !== undefined && f.label !== '') colLabel.set(f.name, f.label);
|
|
}
|
|
const carried = (Array.isArray(fired?.detail?.['carried']) ? (fired.detail['carried'] as unknown[]) : [])
|
|
.map((k) => colLabel.get(String(k)) ?? String(k))
|
|
.filter((k) => k !== '');
|
|
|
|
return {
|
|
rows: Number(wrote?.detail?.['rows'] ?? 0),
|
|
simulated: Number(wrote?.detail?.['simulated'] ?? 0),
|
|
tables,
|
|
ports,
|
|
carried,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 这一单产出的**资料文件**一摞 —— 工作成果里与定稿报告并列。
|
|
*
|
|
* 两个来源,**存档优先**:放行之后那一份已经冻住(归档件点开的必须是他签字那一刻
|
|
* 的那一份);在办期间还没有存档,就照台账现列 —— 人在审的那几份也该看得见。
|
|
* 认行一律 `address + key`,与件上 `data-file` 那一串同一形状:点开走同一条路。
|
|
*
|
|
* 在办这一路挑哪几行与存档同一段(`assetRowsOf`):认**本单碰过的行**,
|
|
* 不是这张表里现有的所有行 —— 两处各写一份的下场见那段的病根记档。
|
|
*/
|
|
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 touched = await container.ledger.taskDrafts(taskId).catch(() => []);
|
|
const out: Array<{ address: string; key: string; title: string; format: string; name: string; archived: boolean }> = [];
|
|
for (const declared of declarations ?? []) {
|
|
if (declared.file === undefined) 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 asset of assetRowsOf(declared, page.rows as Array<Record<string, unknown>>, touched)) {
|
|
out.push({
|
|
address: asset.address,
|
|
key: keyText(asset.key),
|
|
title: asset.title,
|
|
format: asset.format,
|
|
name: safeFileName(asset.title, asset.format),
|
|
archived: false,
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 这一单归哪个活动、它声明过哪几张表 —— 授权面就是这份声明 */
|
|
async function declarationsOf(container: Container, taskId: TaskId) {
|
|
const task = await container.task.get(taskId).catch(() => undefined);
|
|
if (task === undefined) return undefined;
|
|
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 就地修订留一条痕 —— 改了几行、加了几行、撤了哪几行、哪几格没归成型。
|
|
*
|
|
* **撤掉的那几行要点名**:那一条不只是给人看的账,也是**下一拍模型的行动指南** ——
|
|
* 它看不见这一屏,只看得见轨迹。不说清「这一行是人撤的」,它会照着
|
|
* 「条目数 9 而表里 8 行」判成「少写了一行」,转头把人刚删掉的那行写回去。
|
|
*/
|
|
async function noteEdits(container: Container, taskId: TaskId, got: WriteBack): Promise<number> {
|
|
const touched = got.rows + got.added + got.dropped.length;
|
|
// 一行都没落下也要留痕 —— 那正是最该说出来的一次(从前这里把 unkeyed 一并吞了)
|
|
if (touched === 0 && got.bad.length === 0 && got.unkeyed.length === 0) return touched;
|
|
const parts: string[] = [];
|
|
if (got.rows > 0) parts.push(`改了 ${got.rows} 行`);
|
|
/*
|
|
* **加的行要点名**(2026-08-24 柴浩实测):只写「新增 1 行」,模型守着轨迹里
|
|
* 「识别出 1 家」把人加的那家从计划到收口全程无视 —— 这条痕是它下一拍的行动指南,
|
|
* 得说清加的是谁、且与既有的行同等作数。
|
|
*/
|
|
if (got.added > 0) parts.push(`新增 ${got.added} 行(${got.addedKeys.slice(0, 5).join('、')})`);
|
|
if (got.dropped.length > 0) parts.push(`撤掉 ${got.dropped.length} 行(${got.dropped.slice(0, 5).join('、')})`);
|
|
if (got.bad.length > 0) parts.push(`${got.bad.length} 格填的值与列型对不上,已跳过(${got.bad.slice(0, 3).join('、')})`);
|
|
// 没落下的行要在日志里点名 —— 下一拍模型看的是这条痕,人回看也是它
|
|
if (got.unkeyed.length > 0) {
|
|
parts.push(`${got.unkeyed.length} 行没落下(${got.unkeyed.map((u) => u.hint).slice(0, 2).join(';')})`);
|
|
}
|
|
await container.memory.append({
|
|
taskId,
|
|
kind: 'human-input',
|
|
title:
|
|
`就地修订成果:${parts.join(';')}` +
|
|
(got.dropped.length === 0 ? '' : ' —— 撤掉的是人定的,别再写回去;条目数跟着改对') +
|
|
(got.added === 0 ? '' : ' —— 新增的行是人定的,与既有的行同等作数:后续每一步按台账现读的全表办,别按你先前识别的行数办'),
|
|
detail: {
|
|
rows: got.rows,
|
|
...(got.added === 0 ? {} : { added: got.added, addedKeys: got.addedKeys }),
|
|
...(got.dropped.length === 0 ? {} : { dropped: got.dropped }),
|
|
...(got.bad.length === 0 ? {} : { rejected: got.bad }),
|
|
...(got.unkeyed.length === 0 ? {} : { unkeyed: got.unkeyed }),
|
|
},
|
|
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
|
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
|
});
|
|
return touched;
|
|
}
|
|
|
|
/**
|
|
* **件上按「保存」** —— 按**地址**写回,不经过「当下那张卡的第几块」(2026-08-02)。
|
|
*
|
|
* 件里每张可改的表挂着 `data-edit="域.对象"`、每个格子挂着 `data-col="台账列名"`,
|
|
* 地址与列名本来就在件里 —— 前端照原样收上来即可。
|
|
*
|
|
* 从前这条路借道工单的 `blocks[i]`:列名由那张卡给出,于是**「这份件能不能改」
|
|
* 被绑在了「此刻开着的那张卡长什么样」上**。实测的下场:一单在放行门连拒五拍熔断,
|
|
* 引擎发的是一张没有 `blocks` 的熔断卡,而屏上摆着的正是受理记录那份件 ——
|
|
* 闸在、编辑进得去、保存按得下,前端却找不到匹配的块,**一格都没往下送**。
|
|
* 人要补的恰恰是把这一单卡在那儿的那个必填格,而屏上什么反应都没有。
|
|
*
|
|
* 授权面改由**活动清单的台账声明**把关:不在声明里的地址一概不写 ——
|
|
* 比「当下那张卡碰巧带了哪一块」既严格又稳定。
|
|
*/
|
|
async function applyViewTableEdits(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
payload: {
|
|
tables: Readonly<Record<string, readonly EditedRow[]>>;
|
|
drops?: Readonly<Record<string, readonly EditedRow[]>> | undefined;
|
|
},
|
|
): Promise<{ rows: number; unkeyed: WriteBack['unkeyed'] }> {
|
|
const declarations = await declarationsOf(container, taskId);
|
|
if (declarations === undefined) return { rows: 0, unkeyed: [] };
|
|
const got = nothing();
|
|
/*
|
|
* **先撤后写** —— 顺序有意义:人可能把一行删掉、又在同一次保存里加回一行同主键的。
|
|
* 反过来做的话,新写的那一行会被紧接着的撤掉抹平,屏上有而台账里没有。
|
|
*/
|
|
for (const { declared, lines } of writesOf(declarations, payload.drops ?? {})) {
|
|
merge(got, await dropEditedRows(container, taskId, declared, lines));
|
|
}
|
|
for (const { declared, lines } of writesOf(declarations, payload.tables)) {
|
|
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
|
}
|
|
return { rows: await noteEdits(container, taskId, got), unkeyed: got.unkeyed };
|
|
}
|
|
|
|
/** 台账声明的最小形状 —— 这一段只用到这几样 */
|
|
type LedgerDeclaration = {
|
|
domain: string;
|
|
object: string;
|
|
/** 单标识列 —— 摆给人看的行标识里去掉它(rowLabel) */
|
|
caseKey?: string | undefined;
|
|
fields: ReadonlyArray<{
|
|
name: string;
|
|
type: string;
|
|
primaryKey: boolean;
|
|
label?: string | undefined;
|
|
required?: boolean | undefined;
|
|
/** 基座生成的内部流水号 —— 不问人、不让模型编(见 `autoKeys`) */
|
|
auto?: boolean | undefined;
|
|
}>;
|
|
};
|
|
|
|
/**
|
|
* **哪几张表准写** —— 授权面是活动清单的台账声明,不是当下那张卡带了哪一块。
|
|
*
|
|
* 地址对不上号(不是「域.对象」)、活动没声明过的地址,一概不写:
|
|
* 载荷是从浏览器来的,门得在这儿,不能靠件里写着什么就信什么。
|
|
*/
|
|
export function writesOf(
|
|
declarations: readonly LedgerDeclaration[],
|
|
tables: Readonly<Record<string, readonly EditedRow[]>>,
|
|
): Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> {
|
|
const out: Array<{ declared: LedgerDeclaration; lines: readonly EditedRow[] }> = [];
|
|
for (const [address, lines] of Object.entries(tables)) {
|
|
const [domain, object, extra] = address.split('.');
|
|
if (domain === undefined || object === undefined || extra !== undefined) continue;
|
|
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
|
if (declared === undefined) continue; // 活动没声明这张表 —— 不是它的地盘,不写
|
|
out.push({ declared, lines });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* **卡上按「提交」时带着的那张表** —— 回传里第一行是列名,其后一行一条。
|
|
*
|
|
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
|
* 从前这张表的回传只进 AI 的轨迹、由它自己 write 回去 —— 中间隔一道,
|
|
* 它可以抄错、可以顺手改一笔(实测:人填 30000 它写回 30;人留空表示「客户不给」,
|
|
* 它填了隔壁那条料的价)。验收页上更要紧:那一改本来就是**人对成果的修订**,
|
|
* 过一遍 AI 就不叫修订了。
|
|
*
|
|
* 只认**绑了 `ledger` + `fields`** 的表:这条路上列名确实由卡给出(卡上摆的是
|
|
* `ViewBlocks`,格子里没有 `data-col`),所以位置对应仍留在这儿。
|
|
*/
|
|
async function applyBoundTableEdits(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
order: WorkOrder,
|
|
reply: { values: Readonly<Record<string, string>> },
|
|
): Promise<number> {
|
|
const blocks = (order as { blocks?: ReadonlyArray<Record<string, unknown>> }).blocks;
|
|
if (blocks === undefined || blocks.length === 0) return 0;
|
|
const declarations = await declarationsOf(container, taskId);
|
|
if (declarations === undefined) return 0;
|
|
|
|
const got = nothing();
|
|
for (const [index, block] of blocks.entries()) {
|
|
if (block['t'] !== 'table' || typeof block['ledger'] !== 'string' || !Array.isArray(block['fields'])) continue;
|
|
const raw = reply.values[`table:${index}`];
|
|
if (raw === undefined || raw.trim() === '') continue;
|
|
let table: unknown;
|
|
try {
|
|
table = JSON.parse(raw);
|
|
} catch {
|
|
continue; // 解析不了就当没改 —— 宁可少写一次,不要把一份坏数据落进草稿
|
|
}
|
|
if (!Array.isArray(table) || table.length < 2) continue;
|
|
const [domain, object] = String(block['ledger']).split('.');
|
|
if (domain === undefined || object === undefined) continue;
|
|
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
|
if (declared === undefined) continue;
|
|
const fields = (block['fields'] as unknown[]).map((f) => String(f));
|
|
|
|
const lines: EditedRow[] = [];
|
|
for (const line of (table as unknown[]).slice(1)) {
|
|
if (!Array.isArray(line)) continue;
|
|
const cells: Record<string, string> = {};
|
|
fields.forEach((f, i) => {
|
|
cells[f] = String((line as unknown[])[i] ?? '');
|
|
});
|
|
lines.push(cells);
|
|
}
|
|
merge(got, await writeEditedRows(container, taskId, declared, lines));
|
|
}
|
|
return noteEdits(container, taskId, got);
|
|
}
|
|
|
|
/**
|
|
* **提交闸:必填还空着就交不了**(2026-08-02 用户定)。
|
|
*
|
|
* 「必填」由**台账声明**说了算(`fields[].required`),缺几格由基座数(草稿层现读)——
|
|
* 与放行门那条 L1 机检 `域_对象_missing = 0` **同一个数、同一套算法**
|
|
* (都走 `missingReadings`)。同源是要紧的:两处各算各的,就会出现
|
|
* 「卡上说能交、放行门说不行」——那种矛盾人是判不出对错的。
|
|
*
|
|
* 看哪几张表:卡上绑了表就看绑的那几张;**卡上没绑表就回落到必交付物的台账声明**
|
|
* (`requiredDeliverables` 的 `ledgers`/`ledger`)—— 执行卡零输入之后(2026-08-02
|
|
* 用户定:「填写全去工作区」)卡面不再绑 blocks,闸若只认卡上的表就整个失效,
|
|
* 缺着必填照样交得回来,一路走到放行门才被 L1 拦住(那正是当初立这道闸要治的病)。
|
|
* 两条路都算不出(连交付物都没声明台账)才不给闸 —— **没有依据的时候不拦人**。
|
|
*/
|
|
async function submitGate(
|
|
container: Container,
|
|
taskId: TaskId,
|
|
order: WorkOrder,
|
|
): Promise<{ ready: boolean; why: string; blanks: string[] } | undefined> {
|
|
if (order.mode !== 'execution' || order.done !== undefined) return undefined;
|
|
const task = await container.task.get(taskId).catch(() => undefined);
|
|
if (task === undefined) return undefined;
|
|
const manifest = await container.registry.manifestOf(task.activityId).catch(() => undefined);
|
|
if (manifest === undefined) return undefined;
|
|
const addresses = gateAddressesOf(order.blocks, manifest.contract.requiredDeliverables);
|
|
if (addresses.length === 0) return undefined;
|
|
const watched = manifest.declarations.ledger.filter((l) => addresses.includes(`${l.domain}.${l.object}`));
|
|
if (watched.length === 0) return undefined;
|
|
|
|
// 本单写下的行 —— 与任务视图同一约定(`mine:域.对象`)
|
|
const mine: Record<string, unknown[]> = {};
|
|
for (const draft of await container.ledger.taskDrafts(taskId)) {
|
|
(mine[`mine:${draft.domain}.${draft.object}`] ??= []).push({ ...draft.key, ...draft.patch });
|
|
}
|
|
return gateOf(missingReadings(mine, watched), watched);
|
|
}
|
|
|
|
/**
|
|
* 闸看哪几张表:卡上绑了表就看绑的那几张;没绑(执行卡零输入之后是常态)
|
|
* 回落到**必交付物的台账声明**(`ledgers` 全表,老清单退 `ledger` 单张)。
|
|
* 两边都空才真的没有依据。抽成纯函数是为了钉测 —— 回落断了闸就整个失效,
|
|
* 而失效的闸在页面上看不出来(按钮照常绿着)。
|
|
*/
|
|
export function gateAddressesOf(
|
|
blocks: ReadonlyArray<ViewBlock> | undefined,
|
|
deliverables: ReadonlyArray<{ ledger?: string | undefined; ledgers?: ReadonlyArray<string> | undefined }>,
|
|
): string[] {
|
|
const bound = (blocks ?? [])
|
|
.filter((b): b is Extract<ViewBlock, { t: 'table' }> => b.t === 'table' && typeof b.ledger === 'string')
|
|
.map((b) => b.ledger as string);
|
|
if (bound.length > 0) return bound;
|
|
return deliverables.flatMap((d) => [...(d.ledgers ?? (d.ledger === undefined ? [] : [d.ledger]))]);
|
|
}
|
|
|
|
/**
|
|
* 读数 → 闸。**数与名单都来自基座的读数**,这里只负责说人话。
|
|
*
|
|
* 一格不缺就是 `ready: true`(闸不出现);缺了就把「几条 / 哪几行」原样摆出来 ——
|
|
* 只给一个按不下的灰按钮,人只能猜自己哪儿做错了。
|
|
*/
|
|
export function gateOf(
|
|
readings: Readonly<Record<string, string | number>>,
|
|
watched: ReadonlyArray<{ domain: string; object: string }>,
|
|
): { ready: boolean; why: string; blanks: string[] } {
|
|
let short = 0;
|
|
const blanks: string[] = [];
|
|
for (const { domain, object } of watched) {
|
|
short += Number(readings[`${domain}_${object}_missing`] ?? 0);
|
|
const keys = readings[`${domain}_${object}_missing_keys`];
|
|
if (typeof keys === 'string' && keys !== '') blanks.push(...keys.split(';'));
|
|
}
|
|
return short === 0
|
|
? { ready: true, why: '', blanks: [] }
|
|
: { ready: false, why: `还有 ${short} 条必填没填 —— 在工作区的件上补齐才能提交`, blanks };
|
|
}
|
|
|
|
export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): void {
|
|
/** 任务卡 —— 一条 = 一个活动的一次运行;序位取自工作流的节点实例 */
|
|
async function toCard(task: Awaited<ReturnType<Container['task']['get']>>): Promise<TaskCard> {
|
|
let position = 1;
|
|
let total = 1;
|
|
let subject = task.title;
|
|
let workflowState: TaskCard['workflowState'] = 'running';
|
|
|
|
if (task.workflowId !== undefined) {
|
|
const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined);
|
|
if (workflow !== undefined) {
|
|
subject = workflow.subject;
|
|
workflowState = workflow.state;
|
|
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
|
const activityNodes = (pkg?.nodes ?? []).filter((n) => n.kind === 'activity' || n.kind === 'approval');
|
|
total = Math.max(activityNodes.length, 1);
|
|
const index = activityNodes.findIndex((n) => n.id === task.nodeId);
|
|
position = index < 0 ? 1 : index + 1;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 阶段进度 —— 一行运行时读出来(阶段声明与计划项状态同在那一行)。
|
|
* 每张卡一次读:任务清单是「我手上开着的单」,量级是个位数,与上面
|
|
* 已有的按卡取工作流/流程包同一个形状。活动没声明阶段就没有这一格。
|
|
*/
|
|
const phase = await container.loop.phaseOf(task.id, task.terminal !== undefined).catch(() => undefined);
|
|
|
|
return {
|
|
id: task.id,
|
|
activity: task.title,
|
|
title: task.title,
|
|
runtime: task.runtime,
|
|
...(task.terminal === undefined ? {} : { terminal: task.terminal }),
|
|
position,
|
|
total,
|
|
...(phase === undefined ? {} : { phase }),
|
|
workflowSubject: subject,
|
|
workflowState,
|
|
startedAt: task.startedAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 流程链 —— 人要看见自己在整条流程的哪一环,以及前后是谁。
|
|
* 别人负责的节点在链上只读呈现,不进任务清单(清单只装我负责的)。
|
|
*/
|
|
async function toChain(task: Awaited<ReturnType<Container['task']['get']>>): Promise<ChainNode[]> {
|
|
if (task.workflowId === undefined) return [];
|
|
const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined);
|
|
if (workflow === undefined) return [];
|
|
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
|
|
|
/*
|
|
* 链的呈现顺序:**按边的拓扑序**(`advanceOrder`),不吃 `nodes` 数组的书写顺序,
|
|
* 也不看图纸坐标 —— 图纸一回形走线,列位就跟推进次序对不上了。
|
|
*/
|
|
const nodes =
|
|
pkg === undefined ? [] : advanceOrder(pkg).filter((n) => n.kind === 'activity' || n.kind === 'approval');
|
|
|
|
/*
|
|
* 状态取**真实的节点实例**,不按下标推。
|
|
* 按下标推(「排在当前之前的就算做完」)在并行支上会说谎:人在 A3 时 A2 可能还在跑,
|
|
* 却被标成已完成。宁可把在跑的并行支报作待办(少报),也不能把没做完的说成做完了。
|
|
*/
|
|
const instances = await container.process.instancesOf(workflow.id).catch(() => []);
|
|
const finished = new Set(instances.filter((x) => x.terminal !== undefined).map((x) => x.nodeId));
|
|
|
|
return await Promise.all(
|
|
nodes.map(async (n) => {
|
|
/*
|
|
* 报谁,要和**真正派单时的口径一致**:负责人是运营时分配的(员工组轮值 /
|
|
* 全员轮值),分派结果记在实例 assignee 上 —— 起了单的报实际承接人,
|
|
* 还没起的如实报「轮值待派」,不编造具体承接人。活动点与审批点同一口径。
|
|
*/
|
|
const inst = [...instances].reverse().find((x) => x.nodeId === n.id && x.assigneeId !== undefined);
|
|
const who =
|
|
inst?.assigneeId === undefined
|
|
? undefined
|
|
: await container.org.getStaff(staffIdSchema.parse(inst.assigneeId)).catch(() => undefined);
|
|
return {
|
|
nodeId: n.id,
|
|
label: n.label,
|
|
kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const),
|
|
ownerName: who?.name ?? '轮值待派',
|
|
ownerRole: who?.position.name ?? '运营分派',
|
|
state:
|
|
n.id === task.nodeId
|
|
? ('current' as const)
|
|
: finished.has(n.id)
|
|
? ('done' as const)
|
|
: ('todo' as const),
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
app.get(listTasksRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
// 任务要素永远只装「我负责的任务」—— 跨人卡进对方的消息要素,不占任务清单
|
|
const tasks = await container.task.listByOwner(staff.id);
|
|
return c.json({ tasks: await Promise.all(tasks.map(toCard)) });
|
|
});
|
|
|
|
app.get(taskDetailRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
|
const task = await container.task.get(taskId);
|
|
|
|
const [workOrders, chat, lines, pushed] = await Promise.all([
|
|
// 全部工单:待处理要翻页办,已办结要回看定稿报告 —— 两摞同源
|
|
container.humanTool.allWorkOrders(taskId),
|
|
container.interaction.timeline(taskId),
|
|
container.memory.taskLog(taskId),
|
|
container.interaction.viewOf(taskId),
|
|
]);
|
|
const cards = await container.humanTool.chatCards(taskId);
|
|
/*
|
|
* **闸与判据都现算** —— 人在件上补一格、撤一行,按下保存,这一屏跟着重取,
|
|
* 闸该跟着开、判据该跟着变。发卡那一刻算死是不行的:
|
|
* 那时缺的还缺着,而他补的动作正是为了消掉它们。
|
|
*
|
|
* 验收卡那一摞判据从前是发卡时钉死的:人在成果件上把目标价填好,
|
|
* 卡上照旧写着「缺目标价」—— 用户实测点出来的(「这个价格是我填写的,但是它判错了」)。
|
|
* 现在与提交门读同一段(`criterionCheck`),两处不会各说各话。
|
|
*/
|
|
const live = workOrders.some((o) => o.mode === 'acceptance' && o.done === undefined)
|
|
? await container.checks.of(taskId).catch(() => undefined)
|
|
: undefined;
|
|
const landed = workOrders.some((o) => o.mode === 'acceptance' && o.done !== undefined)
|
|
? await landingOf(container, taskId, task.activityId).catch(() => undefined)
|
|
: undefined;
|
|
const gated = await Promise.all(
|
|
workOrders.map(async (order) => {
|
|
if (order.mode === 'acceptance' && order.done === undefined && live !== undefined) {
|
|
// L5 照旧不上卡:那张卡本身就是那道关,列进去等于摆一条恒为「未达标」的项
|
|
return { ...order, criteria: live.filter((c) => c.level !== 'L5') };
|
|
}
|
|
// 办结的验收卡:判据换成落地结果 —— 签完之后人关心的是「发生了什么」
|
|
if (order.mode === 'acceptance' && order.done !== undefined && landed !== undefined) {
|
|
return { ...order, landing: landed };
|
|
}
|
|
const gate = await submitGate(container, taskId, order);
|
|
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`(读出多少字 /
|
|
* 为什么读不出),**不改写**:界面上说「已上传 ✓」而活动其实看不见里面,是撒谎。
|
|
*/
|
|
const commRows = rowsOf<{
|
|
id: string;
|
|
at: Date | string;
|
|
direction: string;
|
|
channel: string;
|
|
party: string;
|
|
subject: string;
|
|
body: string;
|
|
attachments: unknown;
|
|
note: string;
|
|
read_at: unknown;
|
|
}>(
|
|
await container.db.execute(sql`
|
|
select id, at, direction, channel, party, subject, body, attachments, note, read_at
|
|
from sys.task_comm where task_id = ${taskId} order by at`),
|
|
);
|
|
const comms = commRows.map((r) => ({
|
|
id: r.id,
|
|
at: r.at instanceof Date ? r.at.toISOString() : String(r.at),
|
|
direction: r.direction === 'out' ? ('out' as const) : ('in' as const),
|
|
channel: r.channel,
|
|
party: r.party,
|
|
subject: r.subject,
|
|
body: r.body,
|
|
// read_at 为空 = 未读;只由人按按钮改,不随打开页签自动清
|
|
unread: r.read_at === null || r.read_at === undefined,
|
|
attachments: (Array.isArray(r.attachments) ? r.attachments : []).map((a) => {
|
|
const one = (typeof a === 'object' && a !== null ? a : {}) as Record<string, unknown>;
|
|
const text = one['text'];
|
|
return {
|
|
name: String(one['name'] ?? '未命名'),
|
|
...(typeof one['size'] === 'number' ? { size: one['size'] } : {}),
|
|
...(typeof one['ref'] === 'string' ? { ref: one['ref'] } : {}),
|
|
...(typeof one['note'] === 'string' ? { note: one['note'] } : {}),
|
|
readable: typeof text === 'string' && text !== '',
|
|
};
|
|
}),
|
|
note: r.note,
|
|
}));
|
|
const chain = await toChain(task);
|
|
|
|
/*
|
|
* 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。
|
|
* 两处来源:
|
|
* ① 活动推的那一屏 —— 它就是这单当下的汇报报告(AM 07);
|
|
* ② 过程成果里声明的 `files` 块 —— 活动按需生成的目标输出(报价单 / 台账 / 档案)。
|
|
* 活动尚未产出文件时给空列表,界面落空态,不编造条目。
|
|
*/
|
|
/*
|
|
* 过程成果尚无来源:活动还不发布 deliverable(一个个活动开发时逐步补上)。
|
|
* 先声明出来并接进成果文件的装配 —— 活动一开始产出,文件列表就自动有了。
|
|
*/
|
|
const deliverables: Deliverable[] = [];
|
|
|
|
const results: Array<{ label: string; desc: string; at?: string; file: FileDoc }> = [];
|
|
if (pushed?.html !== undefined) {
|
|
results.push({
|
|
label: `${task.title} · 汇报报告`,
|
|
desc: 'HTML · 活动按当下局面组织的一屏',
|
|
file: { kind: 'html', name: `${task.activityId}-report.html`, html: pushed.html },
|
|
});
|
|
}
|
|
for (const deliverable of deliverables) {
|
|
for (const block of deliverable.blocks) {
|
|
if (block.t !== 'files') continue;
|
|
for (const item of block.items) {
|
|
results.push({
|
|
label: item.label,
|
|
desc: item.desc,
|
|
...(deliverable.at === undefined ? {} : { at: deliverable.at }),
|
|
file: item.file,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 对话流 = 时间线 + 对话卡族,**按任务内序号排**(时钟跨机器会偏,序号不会;见 chat-order.ts)
|
|
const merged: ChatMessage[] = orderChat([...chat, ...cards], await container.memory.history(taskId));
|
|
|
|
// 视图区:活动推的 HTML 优先(它是活动按局面组织的完整一屏);
|
|
// 尚无推送时给一份进度概览,不留空屏
|
|
const view: ViewBlock[] = [
|
|
{
|
|
t: 'kv',
|
|
title: '任务概览',
|
|
items: [
|
|
{ k: '活动', v: task.activityId },
|
|
{ k: '负责人', v: staff.name },
|
|
{ k: '状态', v: task.runtime },
|
|
{ k: '已用步数', v: `${task.used.steps} / ${task.caps.steps}` },
|
|
],
|
|
},
|
|
];
|
|
|
|
return c.json({
|
|
card: await toCard(task),
|
|
goal: task.title,
|
|
owner: { name: staff.name, role: staff.position.name },
|
|
chain,
|
|
budget: {
|
|
steps: `${task.used.steps}/${task.caps.steps}`,
|
|
tokens: `${task.used.tokens}/${task.caps.tokens}`,
|
|
activeMinutes: `${Math.round(task.used.activeMs / 60_000)}/${task.caps.activeMinutes}`,
|
|
humanCalls: `${task.used.humanCalls}/${task.caps.humanCalls}`,
|
|
},
|
|
stages: [],
|
|
deliverables,
|
|
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 }),
|
|
log: await displayLog(lines, container.org),
|
|
});
|
|
});
|
|
|
|
/**
|
|
* **就地改完按下「保存」** —— 写回本单草稿层,并**按新数据把件重渲一遍**。
|
|
*
|
|
* 两步缺一不可:
|
|
* ① 落库 —— 不落,人改的东西只活在浏览器里,提交前一刷新就没了;
|
|
* ② 重渲 —— 件是**服务端投影**:只读那几格是他自己敲的,而由数据算出来的东西
|
|
* (类徽 A/B/C、分布条、结论横幅那条去向)全停在上一次渲染那一刻。
|
|
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」——
|
|
* 一半新一半旧,那不是没更新,是界面在撒谎。
|
|
*
|
|
* **工单不办结**:保存只是保存。放行门的位置一点没变 ——
|
|
* 写的仍是草稿层,合并进正式版依旧只发生在人签字那一刻。
|
|
*/
|
|
/*
|
|
* **点开一份资料文件** —— 件上的资料名点下去,走到这里。
|
|
*
|
|
* 三道关,一道都不能省:
|
|
* ① 会话(是不是这个人);
|
|
* ② **授权面 = 活动清单的台账声明** —— 那张表没声明 `file` 段就不给,
|
|
* 与「写看台账声明」「立监听看清单声明」同款;声明里没有的列名也不认;
|
|
* ③ 行取的是**任务视图**(正式版 + 本单草稿)—— 在办的初稿本来就还在草稿层,
|
|
* 只读正式表的话,人在审的这一份恰恰打不开。
|
|
*
|
|
* 拿到行之后**现造**:标题 + 正文 + 格式 → 真字节。`raw=1` 原样交出去(下载),
|
|
* 否则再读回来成 `FileDoc` 给前端渲 —— 读得回来才证明那份文件是真的。
|
|
*/
|
|
app.get(openAssetFileRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
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 = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
|
const parsed = saveTableRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '保存载荷不合规' };
|
|
return c.json(body, 400);
|
|
}
|
|
const task = await container.task.get(taskId);
|
|
if (task.ownerId !== staff.id) {
|
|
const body: ApiError = { code: 'forbidden', message: '这一单不归你办' };
|
|
return c.json(body, 403);
|
|
}
|
|
/*
|
|
* **这一单还在办**才收得下改动 —— 终态之后草稿层已经并进正式版,再改就是改历史。
|
|
* 门只有这一道:改哪张表由**活动声明**把关(见 `applyViewTableEdits`),
|
|
* 不再问「此刻开着的是哪张卡」—— 那道门实测把人真正要补的那一格挡在了外面。
|
|
*/
|
|
if (task.terminal !== undefined) {
|
|
const body: ApiError = { code: 'conflict', message: '这一单已收尾,改不动了' };
|
|
return c.json(body, 409);
|
|
}
|
|
const saved = await applyViewTableEdits(container, taskId, parsed.data);
|
|
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
|
if (saved.rows > 0) await container.views.repush(taskId);
|
|
/*
|
|
* **有行没落下就如实说** —— 保存说成功、东西却不在,是这一屏最伤人的一种谎
|
|
* (2026-08-24 用户实测:件上加的那个客户推到下一步就不见了)。
|
|
* 仍回 200:别的格子是真落下了,不该因为一行没钥匙把整次保存判死;
|
|
* 但把没落下的那几行摆回去,前端据此红字点名,人补上主键再存一次。
|
|
*/
|
|
return c.json({ ok: true, rows: saved.rows, ...(saved.unkeyed.length === 0 ? {} : { unkeyed: saved.unkeyed }) });
|
|
});
|
|
|
|
/** 回传:与后端回传校验**同 schema** —— 前端提交校验用的也是它 */
|
|
app.post(replyWorkOrderRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const parsed = workOrderReplySchema.safeParse((await c.req.json())['reply']);
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
try {
|
|
/*
|
|
* **提交闸在服务端也得成立**(2026-08-02)。
|
|
*
|
|
* 前端把按钮灰掉是给人看的;而**说明书里向模型承诺了**「卡交回来时必填一定是齐的,
|
|
* 所以不许再发同一张卡」——那句承诺只有服务端拦得住才算数。
|
|
* 拦不住的话:一次直调 API 就能把缺必填的卡交回来,模型照承诺不再追问,
|
|
* 这一单一路走到放行门才被 L1 拦住 —— 而那时缺的是哪一格早已翻篇。
|
|
* 与卡面那道闸同一段(`submitGate`),同一个数、同一套算法。
|
|
*/
|
|
/*
|
|
* **闸要在收下回传之前把住**:收下之后再拒,那张单已经记成办结了。
|
|
* 这一单归哪个任务只有库里知道(回传载荷里只有工单号),所以先问一句。
|
|
*/
|
|
const owner = rowsOf<{ task_id: string }>(
|
|
await container.db.execute(sql`select task_id from sys.work_order where id = ${parsed.data.workOrderId} limit 1`),
|
|
)[0];
|
|
if (owner !== undefined) {
|
|
const held = taskIdSchema.parse(owner.task_id);
|
|
const order = (await container.humanTool.allWorkOrders(held)).find((o) => o.id === parsed.data.workOrderId);
|
|
const gate = order === undefined ? undefined : await submitGate(container, held, order);
|
|
if (gate !== undefined && !gate.ready) {
|
|
const body: ApiError = {
|
|
code: 'validation-failed',
|
|
message: `${gate.why}${gate.blanks.length === 0 ? '' : ` —— ${gate.blanks.slice(0, 5).join(';')}`}`,
|
|
};
|
|
return c.json(body, 400);
|
|
}
|
|
}
|
|
const outcome = await container.humanTool.replyWorkOrder(parsed.data, staff.id);
|
|
/*
|
|
* **办结那一刻,把当时的件原样存进这张工单**(2026-08-02 用户:「HTML UI、
|
|
* 数据、内容、布局都要 1:1 地从待处理转换为静态报告放到工作成果」)。
|
|
* 成果页渲的是这份快照 —— 不再渲「当下」的任务视图(随后续推进会变),
|
|
* 更不退回卡壳。快照失败不拦办结(留痕即可,人已交卷)。
|
|
*/
|
|
try {
|
|
const shot = await container.interaction.viewOf(outcome.taskId);
|
|
if (shot?.html !== undefined) {
|
|
await container.db.execute(
|
|
sql`update sys.work_order set payload = jsonb_set(payload, '{finalHtml}', ${JSON.stringify(shot.html)}::jsonb) where id = ${parsed.data.workOrderId}`,
|
|
);
|
|
}
|
|
} catch {
|
|
/* 件还没推过就没有快照 —— 成果页会退回旧路,不拦人交卷 */
|
|
}
|
|
/*
|
|
* **绑台账的可编辑表:人改了什么,基座当场写回草稿层**(2026-08-01)。
|
|
*
|
|
* 与数据录入卡同一条规矩:该由人亲手落下的,别在中间加一道传话。
|
|
* 从前这张表的回传只进 AI 的轨迹,由它自己 write 回去 —— 中间隔一道,
|
|
* 它可以抄错、可以顺手改一笔(实测:人填 30000,它写回 30;
|
|
* 人留空表示「客户不给」,它填了隔壁那条料的价)。
|
|
* 验收页上更要紧:那一改本来就是**人对成果的修订**,过一遍 AI 就不叫修订了。
|
|
*
|
|
* 仍守铁律②:写的是草稿层,放行那一刻才并进正式版。
|
|
*/
|
|
await applyBoundTableEdits(container, outcome.taskId, outcome.order, parsed.data);
|
|
/*
|
|
* **返回之前就把这单标成「在办事」**(2026-08-25 用户:「右边聊天框先有反应,中间的
|
|
* html 过一会儿才变成 AI 正在推进中,看上去很不流畅」)。
|
|
* 从前 runtime 要等派发器投递、推进器抢到租约、开跑前那一刻才翻成 running ——
|
|
* 前端 onSuccess 那一次重取抓到的还是 awaiting-event:对话区(卡状态已落)
|
|
* 先变,工作区(读 runtime)要等下一次推送。两块该读同一份事实。
|
|
*/
|
|
await container.task.wake(outcome.taskId).catch(() => false);
|
|
// 人答完了就接着跑,别让他对着不动的屏幕等两轮轮询(见 workers/pulse.ts)
|
|
void pulse();
|
|
return c.json({ ok: true, producesCompletion: outcome.producesCompletion });
|
|
} catch (error) {
|
|
if (error instanceof ReplyRejectedError) {
|
|
// 驳回必附意见 / 必填未回填 / 已答不得再答 —— 三规矩的回传面
|
|
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations };
|
|
return c.json(body, 400);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 对话卡回传(决策 / 补全 / 评价)。
|
|
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
|
*/
|
|
app.post(replyChatCardRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const parsed = chatCardReplySchema.safeParse((await c.req.json())['reply']);
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
try {
|
|
const { card, taskId: cardTaskId } = await container.humanTool.replyChatCard(parsed.data, staff.id);
|
|
/*
|
|
* **数据录入卡:确认那一下就是写入**(2026-07-31 用户定)。
|
|
*
|
|
* 别的卡是「把值交给 AI,AI 再决定写什么」——中间隔一道传话,
|
|
* 它可以抄错、可以顺手改一笔,而人不知道自己填的东西最后落成了什么。
|
|
* 这一类不同:卡上写明落哪张表、哪个主键,人按下确认,基座**当场**落进草稿层,
|
|
* AI 不经手。建客户档案这类事就该这样 —— 主数据建错一次,后面每一单都跟着错。
|
|
*
|
|
* 仍守铁律②:写的是**草稿层**,放行那一刻才并进正式版 —— 人的确认不越过放行门。
|
|
*/
|
|
if (parsed.data.k === 'entry' && card.k === 'entry') {
|
|
/*
|
|
* **一张卡可以落好几张表**(2026-08-02:建一位新客户 = 公司档 + 联系人档)。
|
|
* 值按段隔开(`段号.列名`,主段 `0.`)—— 两张表都有 `name` 这一列,
|
|
* 平铺就会互相盖掉,而盖掉之后没人看得出来。
|
|
*/
|
|
const groups = [
|
|
{ ledger: card.ledger, key: card.key },
|
|
...(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;
|
|
const patch: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(parsed.data.values)) {
|
|
const mark = `${at}.`;
|
|
if (k.startsWith(mark)) patch[k.slice(mark.length)] = v;
|
|
// 老形状(没段号的平键)只喂主段 —— 一张卡只有一段时它仍旧管用
|
|
else if (at === 0 && !/^\d+\./.test(k)) patch[k] = v;
|
|
}
|
|
if (Object.keys(patch).length === 0) continue;
|
|
const declared = declarations.find((l) => l.domain === domain && l.object === object);
|
|
// 本段的非自增主键:这一段自己填的(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;
|
|
}
|
|
}
|
|
{
|
|
/*
|
|
* 这里**不再另记一条** —— `replyChatCard` 已经把这一下记全了
|
|
* (谁、写入哪张表、几个格子各是什么)。从前两处各记一条,
|
|
* 同一次点击在日志里出现两行:「柴浩 确认录入 customer.profile」
|
|
* 紧跟着「entry 卡回传」,读起来像人点了两次。
|
|
*/
|
|
}
|
|
}
|
|
/*
|
|
* **决策卡答完那一刻,把当时的件原样存进这张卡**(与工单同一条道理,见
|
|
* `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 秒)那段时间对话区一动不动,
|
|
* 人以为没点上,会再点一次。见 `workers/pulse.ts`。
|
|
*/
|
|
await container.task.wake(cardTaskId).catch(() => false); // 与工单回传同一条理:返回前先标「在办」
|
|
void pulse();
|
|
return c.json({ ok: true });
|
|
} catch (error) {
|
|
if (error instanceof ReplyRejectedError) {
|
|
// 补全两条路必走其一 / 已答不得再答 —— 三规矩的回传面
|
|
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations };
|
|
return c.json(body, 400);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */
|
|
/**
|
|
* 标记沟通记录已读 / 未读 —— **只认按钮**。
|
|
*
|
|
* 不做「打开页签即已读」:那等于替人宣布他看过了,而他可能只是路过。
|
|
* 反向也留着(已读改回未读):看了一半想留个记号是常事。
|
|
* 只许改**自己负责的那一单**的记录 —— 别人的单轮不到你替他标已读。
|
|
*/
|
|
app.post(markCommReadRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const parsed = markCommReadRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues };
|
|
return c.json(body, 400);
|
|
}
|
|
const commId = c.req.param('commId');
|
|
const rows = rowsOf<{ task_id: string; subject: string; read_at: unknown }>(
|
|
await container.db.execute(sql`select task_id, subject, read_at from sys.task_comm where id = ${commId}`),
|
|
);
|
|
const taskId = rows[0]?.task_id;
|
|
if (taskId === undefined) {
|
|
const body: ApiError = { code: 'not-found', message: '沟通记录不存在' };
|
|
return c.json(body, 404);
|
|
}
|
|
const task = await container.task.get(taskIdSchema.parse(taskId));
|
|
if (task === undefined || task.ownerId !== staff.id) {
|
|
const body: ApiError = { code: 'forbidden', message: '这一单不归你,标不了它的沟通记录' };
|
|
return c.json(body, 403);
|
|
}
|
|
/*
|
|
* **第一次看过要留痕**(2026-07-31 用户定)——「谁在什么时候看了这封信」
|
|
* 是回看这一单时最想知道的事之一:客户抱怨没人理他,日志里得答得出
|
|
* 「07-31 13:34 柴浩看过了」。只记**第一次**:反复标来标去不是事实的变化,
|
|
* 是人在做记号,记满一屏反而把真事实淹了。
|
|
*/
|
|
const firstRead = parsed.data.read && (rows[0]?.read_at === null || rows[0]?.read_at === undefined);
|
|
await container.db.execute(
|
|
parsed.data.read
|
|
? sql`update sys.task_comm set read_at = now() where id = ${commId}`
|
|
: sql`update sys.task_comm set read_at = null where id = ${commId}`,
|
|
);
|
|
if (firstRead) {
|
|
await container.memory.append({
|
|
taskId: taskIdSchema.parse(taskId),
|
|
kind: 'human-input',
|
|
// 名字**不拼进标题**:主语由日志统一按 `by` 换成名字 —— 拼死在这里,
|
|
// 改个显示口径就得改留痕,而留痕是只增不改的
|
|
title: '看过了邮件',
|
|
/*
|
|
* 主题落 `summary` 而不是塞进标题 —— 日志一行是「谁干了什么」,
|
|
* 副行才是「哪一件」。塞进标题会让这一行长到把别的条目挤扁,
|
|
* 而人扫日志时先看的是动作,确认是哪封信是第二眼的事。
|
|
*/
|
|
detail: { commId, by: staff.id, act: 'read-comm', summary: rows[0]?.subject ?? '' },
|
|
// 版本戳:这条不由活动产出,与引擎那几条同一口径
|
|
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
|
});
|
|
}
|
|
// 气泡计数长在任务投影上 —— 标完要让那一屏自己动,不能等人再点一次
|
|
container.broadcaster.publish({ kind: 'task', id: taskId, reason: 'comm-read' });
|
|
return c.json({ ok: true, unread: !parsed.data.read });
|
|
});
|
|
|
|
app.post(sayRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const taskId = taskIdSchema.parse(c.req.param('taskId'));
|
|
const parsed = sayRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '入参不合法' };
|
|
return c.json(body, 400);
|
|
}
|
|
return c.json(await container.interaction.say(taskId, staff.id, parsed.data.text));
|
|
});
|
|
|
|
app.get(listMessagesRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const messages = await container.message.inbox(staff.id);
|
|
/*
|
|
* 待确认起单(触发确认链路):`eventRef` 挂着待确认单号的消息,
|
|
* 就地贴出那一票的状态 —— 界面靠它决定「确认起单」钮是活的还是已收线。
|
|
*/
|
|
const pendingIds = messages
|
|
.map((m) => m.eventRef)
|
|
.filter((r): r is string => r !== undefined && r.startsWith('trigger-pending:'))
|
|
.map((r) => r.slice('trigger-pending:'.length));
|
|
const pendings = new Map((await container.trigger.pendingByIds(pendingIds)).map((x) => [x.id, x]));
|
|
return c.json({
|
|
messages: messages.map((m) => ({
|
|
id: m.id,
|
|
kind: m.kind,
|
|
title: m.title,
|
|
body: m.body,
|
|
at: m.at,
|
|
read: m.readAt !== undefined,
|
|
...(() => {
|
|
const ref = m.eventRef;
|
|
if (ref === undefined || !ref.startsWith('trigger-pending:')) return {};
|
|
const pending = pendings.get(ref.slice('trigger-pending:'.length));
|
|
if (pending === undefined) return {};
|
|
return {
|
|
confirm: {
|
|
pendingId: pending.id,
|
|
consumed: pending.consumedAt !== undefined,
|
|
...(pending.workflowId === undefined ? {} : { workflowId: pending.workflowId }),
|
|
},
|
|
};
|
|
})(),
|
|
// 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务
|
|
...(m.card === undefined
|
|
? {}
|
|
: {
|
|
card: {
|
|
cardId: m.card.cardId,
|
|
sourceTaskId: m.card.sourceTaskId,
|
|
fields: (m.card.payload['fields'] as unknown[] | undefined) ?? [],
|
|
ask: String(m.card.payload['ask'] ?? ''),
|
|
answered: m.card.answeredAt !== undefined,
|
|
...(m.card.rejectedReason === undefined ? {} : { rejectedReason: m.card.rejectedReason }),
|
|
...(m.card.supersededBy === undefined ? {} : { supersededBy: m.card.supersededBy }),
|
|
},
|
|
}),
|
|
})),
|
|
});
|
|
});
|
|
|
|
/** 确认起单(触发确认链路第 5 步)—— 幂等;只有被点名的确认人按得动 */
|
|
app.post(confirmTriggerPendingRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const id = c.req.param('id');
|
|
const pending = (await container.trigger.pendingByIds([id]))[0];
|
|
if (pending === undefined) {
|
|
const body: ApiError = { code: 'not-found', message: `无此待确认单:${id}` };
|
|
return c.json(body, 404);
|
|
}
|
|
if (pending.staffId !== staff.id) {
|
|
const body: ApiError = { code: 'forbidden', message: '这一票点名的确认人不是你' };
|
|
return c.json(body, 403);
|
|
}
|
|
try {
|
|
return c.json(await container.trigger.confirmPending(id, staff.id));
|
|
} catch (error) {
|
|
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
|
|
return c.json(body, 400);
|
|
}
|
|
});
|
|
|
|
app.post(markMessageReadRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
await container.message.markRead(c.req.param('messageId'));
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* 跨任务征答的**就地作答**(OS 2.1)。
|
|
* 「作答权限只及卡面与决策包,**不开放任务对话流**」——所以这里只收卡面回传,
|
|
* 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。
|
|
*/
|
|
app.post(answerMessageRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) {
|
|
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
return c.json(body, 401);
|
|
}
|
|
const parsed = answerMessageRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '回传不合协议' };
|
|
return c.json(body, 400);
|
|
}
|
|
const answered = await container.message.answer(c.req.param('messageId'), staff.id, parsed.data.answer);
|
|
// 跨人卡一答,来源任务就可能续跑 —— 让那边的浏览器重取投影
|
|
if (answered.card !== undefined) {
|
|
container.broadcaster.publish({ kind: 'task', id: answered.card.sourceTaskId, reason: 'elicitation-answer' });
|
|
}
|
|
return c.json({ ok: true });
|
|
});
|
|
}
|