Archived
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kAzS35kMnZNt4YGgJXtfw
208 lines
8.2 KiB
TypeScript
208 lines
8.2 KiB
TypeScript
/**
|
|
* 工作台后两个要素的路由:纪要与汇报(编码规范 §3)。
|
|
*
|
|
* 「五要素同源 —— 都是同一份过程事实在员工视角下的投影。」
|
|
* 纪要是唯一的主动动作;汇报是系统代劳的自动产出,员工只确认。
|
|
*/
|
|
import type { Hono } from 'hono';
|
|
import type { ApiError, MinuteView, ReportView } from '@contracts';
|
|
import {
|
|
appendResolutionRoute,
|
|
createMinuteRoute,
|
|
draftReportRoute,
|
|
listMinutesRoute,
|
|
listReportsRoute,
|
|
registerResolutionRoute,
|
|
reportPeriodSchema,
|
|
staffIdSchema,
|
|
submitReportRoute,
|
|
} from '@contracts';
|
|
import { CallInterfaceViolationError } from '@modules/process';
|
|
import type { Minute } from '@modules/minute';
|
|
import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute';
|
|
import type { Report } from '@modules/report';
|
|
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
|
import type { Container } from '../container.ts';
|
|
import { staffOf } from '../http-env.ts';
|
|
import type { AppEnv } from '../http-env.ts';
|
|
|
|
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
|
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
|
|
|
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
|
const toReportView = (report: Report): ReportView => ({
|
|
id: report.id,
|
|
period: report.period,
|
|
title: report.title,
|
|
window: report.window,
|
|
sections: report.draft.sections.map((s) => ({
|
|
taskId: s.taskId,
|
|
title: s.title,
|
|
activityId: s.activityId,
|
|
...(s.terminalLabel === undefined ? {} : { terminalLabel: s.terminalLabel }),
|
|
lines: [...s.lines],
|
|
})),
|
|
metrics: report.draft.metrics,
|
|
counts: report.draft.counts,
|
|
...(report.note === undefined ? {} : { note: report.note }),
|
|
...(report.submittedAt === undefined ? {} : { submittedAt: report.submittedAt }),
|
|
generatedAt: report.generatedAt,
|
|
});
|
|
|
|
/* ── 纪要 ─────────────────────────────────────────────────────────── */
|
|
|
|
async function toMinuteView(minute: Minute): Promise<MinuteView> {
|
|
const author = await container.org.getStaff(staffIdSchema.parse(minute.authorId)).catch(() => undefined);
|
|
return {
|
|
id: minute.id,
|
|
title: minute.title,
|
|
body: minute.body,
|
|
authorId: staffIdSchema.parse(minute.authorId),
|
|
authorName: author?.name ?? '—',
|
|
participants: [...minute.participants],
|
|
occurredOn: minute.occurredOn,
|
|
resolutions: minute.resolutions.map((r) => ({
|
|
id: r.id,
|
|
text: r.text,
|
|
...(r.ownerId === undefined ? {} : { ownerId: staffIdSchema.parse(r.ownerId) }),
|
|
state: r.state,
|
|
...(r.workflowId === undefined ? {} : { workflowId: r.workflowId }),
|
|
...(r.registeredById === undefined ? {} : { registeredById: staffIdSchema.parse(r.registeredById) }),
|
|
...(r.registeredAt === undefined ? {} : { registeredAt: r.registeredAt }),
|
|
})),
|
|
createdAt: minute.createdAt,
|
|
updatedAt: minute.updatedAt,
|
|
};
|
|
}
|
|
|
|
app.get(listMinutesRoute.path, async (c) => {
|
|
// 纪要是团队沉淀:全员可见,不按负责人过滤
|
|
const minutes = await container.minute.list();
|
|
return c.json({ minutes: await Promise.all(minutes.map(toMinuteView)) });
|
|
});
|
|
|
|
app.post(createMinuteRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const parsed = createMinuteRoute.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 minute = await container.minute.create({
|
|
title: parsed.data.title,
|
|
body: parsed.data.body,
|
|
authorId: staff.id,
|
|
occurredOn: parsed.data.occurredOn,
|
|
participants: parsed.data.participants,
|
|
resolutions: parsed.data.resolutions,
|
|
});
|
|
return c.json(await toMinuteView(minute));
|
|
});
|
|
|
|
app.post(appendResolutionRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '决议不合协议' };
|
|
return c.json(body, 400);
|
|
}
|
|
try {
|
|
const minute = await container.minute.appendResolution(
|
|
c.req.param('minuteId'),
|
|
parsed.data.text,
|
|
parsed.data.ownerId,
|
|
);
|
|
return c.json(await toMinuteView(minute));
|
|
} catch (error) {
|
|
if (error instanceof MinuteNotFoundError) {
|
|
const body: ApiError = { code: 'not-found', message: error.message };
|
|
return c.json(body, 404);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
|
app.post(registerResolutionRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
|
if (!parsed.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '登记入参不合法' };
|
|
return c.json(body, 400);
|
|
}
|
|
try {
|
|
return c.json(
|
|
await container.minute.register({
|
|
resolutionId: c.req.param('resolutionId'),
|
|
byId: staff.id,
|
|
processPackageId: parsed.data.processPackageId,
|
|
data: parsed.data.data,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ResolutionAlreadyRegisteredError) {
|
|
// 同一决议不重复起单 —— 已经有一条带来源的流在跑了
|
|
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
|
return c.json(body, 412);
|
|
}
|
|
if (error instanceof CallInterfaceViolationError) {
|
|
// 不合调用接口契约:一条工作流都没建,直接回 400 让登记人补齐
|
|
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.errors };
|
|
return c.json(body, 400);
|
|
}
|
|
if (error instanceof MinuteNotFoundError) {
|
|
const body: ApiError = { code: 'not-found', message: error.message };
|
|
return c.json(body, 404);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
|
|
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
|
|
|
app.get(listReportsRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const reports = await container.report.list(staff.id);
|
|
return c.json({ reports: reports.map(toReportView) });
|
|
});
|
|
|
|
app.get(draftReportRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
|
if (!period.success) {
|
|
const body: ApiError = { code: 'validation-failed', message: '周期只有日 / 周 / 月 / 年四种' };
|
|
return c.json(body, 400);
|
|
}
|
|
// 未提交的每次取回都重算 —— 汇报是投影,不是账本
|
|
return c.json(toReportView(await container.report.draft(staff.id, period.data)));
|
|
});
|
|
|
|
app.post(submitReportRoute.path, async (c) => {
|
|
const staff = staffOf(c);
|
|
if (staff === undefined) return c.json(unauthenticated, 401);
|
|
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
|
try {
|
|
const report = await container.report.submit(
|
|
c.req.param('reportId'),
|
|
parsed.success ? parsed.data.note : undefined,
|
|
);
|
|
return c.json(toReportView(report));
|
|
} catch (error) {
|
|
if (error instanceof ReportAlreadySubmittedError) {
|
|
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
|
return c.json(body, 412);
|
|
}
|
|
if (error instanceof ReportNotFoundError) {
|
|
const body: ApiError = { code: 'not-found', message: error.message };
|
|
return c.json(body, 404);
|
|
}
|
|
throw error;
|
|
}
|
|
});
|
|
}
|