forked from ChaiHao/BPOS-MVP
M6(3/n): minute 与 report —— L8 两个模块补齐
minute(纪要):工作台五要素里唯一的主动动作。 纪要本身不产生责任,唯一通往责任的路是决议经登记触发起单 —— 登记人即来源。 正文可续、决议只增;同一决议不重复起单(起单一刻已有一条带来源的流在跑)。 report(汇报):日/周/月/年对任务日志的聚合投影,零手工汇报。 未提交的草案每次取回都重算 —— 汇报是投影不是账本,提交那刻才固定为事实快照。 度量八指标落在这里(§4 注:度量是只读投影,不设独立模块): 自主率 / 纠正率 / 有效自主率 / 打扰率 / 断点率 / DMN 覆盖率 / 单位成本 / 终态分布。 口径全部挂归侧标签与事件流,零埋点;单位成本的分母只认放行通过。 周窗口以周一为首日,按 UTC 切分(单组织单时区)。 FactSource 只读 memory 与 task,一行都不另记 —— 五要素同源。 gates: tsc / eslint / depcruise(305 模块)/ api-report / 377 测试 全绿 日报已在真实系统上由 A1 任务日志聚合出来,含治理干预两行 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* module: minute —— 对外类型(编码规范 §10.1;语义出处 OS 2.1 / 3.1)。
|
||||||
|
*
|
||||||
|
* 纪要是工作台五要素里**唯一的主动动作**:团队主动维护上传的沉淀(会议纪要等)。
|
||||||
|
* 它本身不产生责任;责任的产生只有一条路——**决议经登记触发转为工作流的启动**,
|
||||||
|
* 「登记人即来源」。所以本模块的写面很薄,重头在「决议 → 登记起单」这一跳。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Minute, MinuteResolution, ResolutionState } from './internal/domain/minute.ts';
|
||||||
|
export type { Minute, MinuteResolution, ResolutionState };
|
||||||
|
export declare class MinuteNotFoundError extends Error {
|
||||||
|
readonly minuteId: string;
|
||||||
|
constructor(minuteId: string);
|
||||||
|
}
|
||||||
|
export declare class ResolutionAlreadyRegisteredError extends Error {
|
||||||
|
readonly resolutionId: string;
|
||||||
|
readonly workflowId: string;
|
||||||
|
constructor(resolutionId: string, workflowId: string);
|
||||||
|
}
|
||||||
|
export type CreateMinuteInput = {
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
readonly authorId: StaffId;
|
||||||
|
/** 与会/相关人 —— 仅作沉淀标注,不产生责任 */
|
||||||
|
readonly participants?: readonly StaffId[];
|
||||||
|
readonly occurredOn: string;
|
||||||
|
readonly resolutions?: ReadonlyArray<{
|
||||||
|
text: string;
|
||||||
|
ownerId?: StaffId | undefined;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
export type RegisterInput = {
|
||||||
|
readonly resolutionId: string;
|
||||||
|
/** 登记人 —— 凡单必有来源,登记触发的来源就是他 */
|
||||||
|
readonly byId: StaffId;
|
||||||
|
readonly processPackageId: string;
|
||||||
|
/** 按调用接口 schema 填写的输入(表单由 schema 自动渲染) */
|
||||||
|
readonly data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
export type MinuteService = {
|
||||||
|
list(limit?: number): Promise<Minute[]>;
|
||||||
|
get(minuteId: string): Promise<Minute>;
|
||||||
|
create(input: CreateMinuteInput): Promise<Minute>;
|
||||||
|
/** 只增不改的编辑:正文可续,决议只增 —— 纪要是沉淀,不是可覆写的文档 */
|
||||||
|
appendResolution(minuteId: string, text: string, ownerId?: StaffId): Promise<Minute>;
|
||||||
|
/** 决议 → 登记起单:登记人即来源;同一决议只起一次单 */
|
||||||
|
register(input: RegisterInput): Promise<{
|
||||||
|
workflowId: string;
|
||||||
|
resolutionId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** module: minute —— 唯一出口(编码规范 §10.1):纯再导出,不含实现。 */
|
||||||
|
export type { CreateMinuteInput, Minute, MinuteResolution, MinuteService, RegisterInput, ResolutionState, } from './contract.ts';
|
||||||
|
export { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from './contract.ts';
|
||||||
|
export { createMinuteService } from './internal/usecase/minute-service.ts';
|
||||||
|
export { createMinuteStore } from './internal/adapter/minute-store.ts';
|
||||||
|
export { minutes, minuteResolutions } from './internal/adapter/minute-schema.ts';
|
||||||
|
export { newMinute, addResolution, markRegistered } from './internal/domain/minute.ts';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* module: minute 的外部所需(编码规范 §10.1)。
|
||||||
|
* 「port 声明本模块需要外界提供什么」—— 由 app 层注入,模块自己不认实现。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Minute } from './internal/domain/minute.ts';
|
||||||
|
export type MinuteStore = {
|
||||||
|
list(limit: number): Promise<Minute[]>;
|
||||||
|
get(minuteId: string): Promise<Minute | undefined>;
|
||||||
|
save(minute: Minute): Promise<void>;
|
||||||
|
};
|
||||||
|
/** 登记起单 —— app 注入 `process`(L7):经调用接口调起流程包,不碰流程内部 */
|
||||||
|
export type RegistrationGateway = {
|
||||||
|
start(input: {
|
||||||
|
processPackageId: string;
|
||||||
|
subject: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
/** 来源:登记人 */
|
||||||
|
byId: StaffId;
|
||||||
|
}): Promise<{
|
||||||
|
workflowId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
export type MinuteDeps = {
|
||||||
|
readonly store: MinuteStore;
|
||||||
|
readonly registration: RegistrationGateway;
|
||||||
|
readonly ids: {
|
||||||
|
next(): string;
|
||||||
|
};
|
||||||
|
readonly clock: {
|
||||||
|
now(): Date;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* module: report —— 对外类型(编码规范 §10.1;语义出处 OS 2.1 / 8.1 / 8.3)。
|
||||||
|
*
|
||||||
|
* 汇报是**系统代劳的自动产出**:由本人各任务的任务日志聚合生成,确认即提交,零手工汇报。
|
||||||
|
* 度量八指标是任务日志与终态之上的**只读投影**,不设独立模块(§4 注)。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Metrics, ReportDraft, ReportSection } from './internal/domain/aggregate.ts';
|
||||||
|
import type { ReportPeriod, Window } from './internal/domain/period.ts';
|
||||||
|
export type { Metrics, ReportDraft, ReportPeriod, ReportSection, Window };
|
||||||
|
export declare class ReportNotFoundError extends Error {
|
||||||
|
readonly reportId: string;
|
||||||
|
constructor(reportId: string);
|
||||||
|
}
|
||||||
|
export declare class ReportAlreadySubmittedError extends Error {
|
||||||
|
readonly reportId: string;
|
||||||
|
constructor(reportId: string);
|
||||||
|
}
|
||||||
|
/** 汇报单:草案由系统生成,员工确认即提交 */
|
||||||
|
export type Report = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly staffId: StaffId;
|
||||||
|
readonly period: ReportPeriod;
|
||||||
|
readonly title: string;
|
||||||
|
readonly window: Window;
|
||||||
|
readonly draft: ReportDraft;
|
||||||
|
/** 员工可在确认前补一段话 —— 唯一的手工位 */
|
||||||
|
readonly note?: string | undefined;
|
||||||
|
readonly submittedAt?: string | undefined;
|
||||||
|
readonly generatedAt: string;
|
||||||
|
};
|
||||||
|
export type ReportService = {
|
||||||
|
/** 生成(或取回)某人某周期的汇报草案 —— 未提交的重取即重算,提交后固定 */
|
||||||
|
draft(staffId: StaffId, period: ReportPeriod, anchor?: Date): Promise<Report>;
|
||||||
|
list(staffId: StaffId, limit?: number): Promise<Report[]>;
|
||||||
|
get(reportId: string): Promise<Report>;
|
||||||
|
/** 确认即提交 */
|
||||||
|
submit(reportId: string, note?: string): Promise<Report>;
|
||||||
|
/** 度量八指标 —— 治理台记忆区的仪表读数,同源同口径 */
|
||||||
|
metrics(staffId: StaffId | undefined, period: ReportPeriod, anchor?: Date): Promise<Metrics>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** module: report —— 唯一出口(编码规范 §10.1):纯再导出,不含实现。 */
|
||||||
|
export type { Metrics, Report, ReportDraft, ReportPeriod, ReportSection, ReportService, Window } from './contract.ts';
|
||||||
|
export { ReportAlreadySubmittedError, ReportNotFoundError } from './contract.ts';
|
||||||
|
export type { FactSource, ReportStore, ReportDeps } from './port.ts';
|
||||||
|
export type { LogFact, TaskFact } from './internal/domain/aggregate.ts';
|
||||||
|
export { computeMetrics, draftReport } from './internal/domain/aggregate.ts';
|
||||||
|
export { REPORT_PERIODS, periodLabel, windowOf } from './internal/domain/period.ts';
|
||||||
|
export { createReportService } from './internal/usecase/report-service.ts';
|
||||||
|
export { createReportStore } from './internal/adapter/report-store.ts';
|
||||||
|
export { reports } from './internal/adapter/report-schema.ts';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* module: report 的外部所需(编码规范 §10.1)。
|
||||||
|
* 汇报**不自己记账**:事实全部来自 memory(任务日志)与 task(终态 / 预算 / 纠正信号)。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Report } from './contract.ts';
|
||||||
|
import type { LogFact, TaskFact } from './internal/domain/aggregate.ts';
|
||||||
|
export type ReportStore = {
|
||||||
|
list(staffId: StaffId, limit: number): Promise<Report[]>;
|
||||||
|
get(reportId: string): Promise<Report | undefined>;
|
||||||
|
findOpen(staffId: StaffId, period: string, windowFrom: string): Promise<Report | undefined>;
|
||||||
|
save(report: Report): Promise<void>;
|
||||||
|
};
|
||||||
|
/** 事实源 —— app 注入 `memory`(L2)与 `task`(L6);report 在 L8,只读不写 */
|
||||||
|
export type FactSource = {
|
||||||
|
/** 某人(或全员)在窗口内的归侧日志行 */
|
||||||
|
logsOf(staffId: StaffId | undefined, from: string, to: string): Promise<LogFact[]>;
|
||||||
|
tasksOf(staffId: StaffId | undefined): Promise<TaskFact[]>;
|
||||||
|
};
|
||||||
|
export type ReportDeps = {
|
||||||
|
readonly store: ReportStore;
|
||||||
|
readonly facts: FactSource;
|
||||||
|
readonly ids: {
|
||||||
|
next(): string;
|
||||||
|
};
|
||||||
|
readonly clock: {
|
||||||
|
now(): Date;
|
||||||
|
};
|
||||||
|
};
|
||||||
+2
-1
@@ -56,7 +56,8 @@ export type ParadigmEngine = {
|
|||||||
export type MemoryRecorder = {
|
export type MemoryRecorder = {
|
||||||
append(input: {
|
append(input: {
|
||||||
taskId: TaskId;
|
taskId: TaskId;
|
||||||
kind: 'engine-transition' | 'terminal' | 'observation';
|
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||||
|
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||||
title: string;
|
title: string;
|
||||||
detail?: Record<string, unknown>;
|
detail?: Record<string, unknown>;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|||||||
@@ -2024,3 +2024,382 @@ export declare const listMessagesRoute: {
|
|||||||
}, z.core.$strip>>;
|
}, z.core.$strip>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
};
|
};
|
||||||
|
export declare const minuteResolutionSchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
open: "open";
|
||||||
|
registered: "registered";
|
||||||
|
}>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
registeredById: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
registeredAt: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export declare const minuteSchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
authorId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
authorName: z.ZodString;
|
||||||
|
participants: z.ZodArray<z.ZodString>;
|
||||||
|
occurredOn: z.ZodString;
|
||||||
|
resolutions: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
open: "open";
|
||||||
|
registered: "registered";
|
||||||
|
}>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
registeredById: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
registeredAt: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
updatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type MinuteView = z.infer<typeof minuteSchema>;
|
||||||
|
export declare const listMinutesRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/minutes";
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
minutes: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
authorId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
authorName: z.ZodString;
|
||||||
|
participants: z.ZodArray<z.ZodString>;
|
||||||
|
occurredOn: z.ZodString;
|
||||||
|
resolutions: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
open: "open";
|
||||||
|
registered: "registered";
|
||||||
|
}>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
registeredById: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
registeredAt: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
updatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const createMinuteRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/minutes";
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
title: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
occurredOn: z.ZodString;
|
||||||
|
participants: z.ZodDefault<z.ZodArray<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>>;
|
||||||
|
resolutions: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
authorId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
authorName: z.ZodString;
|
||||||
|
participants: z.ZodArray<z.ZodString>;
|
||||||
|
occurredOn: z.ZodString;
|
||||||
|
resolutions: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
open: "open";
|
||||||
|
registered: "registered";
|
||||||
|
}>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
registeredById: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
registeredAt: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
updatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const appendResolutionRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/minutes/:minuteId/resolutions";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
minuteId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
body: z.ZodString;
|
||||||
|
authorId: z.core.$ZodBranded<z.ZodString, "StaffId", "out">;
|
||||||
|
authorName: z.ZodString;
|
||||||
|
participants: z.ZodArray<z.ZodString>;
|
||||||
|
occurredOn: z.ZodString;
|
||||||
|
resolutions: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
text: z.ZodString;
|
||||||
|
ownerId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
state: z.ZodEnum<{
|
||||||
|
open: "open";
|
||||||
|
registered: "registered";
|
||||||
|
}>;
|
||||||
|
workflowId: z.ZodOptional<z.ZodString>;
|
||||||
|
registeredById: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "StaffId", "out">>;
|
||||||
|
registeredAt: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
createdAt: z.ZodString;
|
||||||
|
updatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** 决议 → 登记起单:登记人即来源 */
|
||||||
|
export declare const registerResolutionRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/minutes/resolutions/:resolutionId/register";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
resolutionId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
processPackageId: z.ZodString;
|
||||||
|
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
workflowId: z.ZodString;
|
||||||
|
resolutionId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const reportPeriodSchema: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
export type ReportPeriodName = z.infer<typeof reportPeriodSchema>;
|
||||||
|
export declare const REPORT_PERIOD_LABEL: Readonly<Record<ReportPeriodName, string>>;
|
||||||
|
export declare const reportMetricsSchema: z.ZodObject<{
|
||||||
|
autonomy: z.ZodNumber;
|
||||||
|
correction: z.ZodNumber;
|
||||||
|
effectiveAutonomy: z.ZodNumber;
|
||||||
|
interruption: z.ZodNumber;
|
||||||
|
breakpoint: z.ZodNumber;
|
||||||
|
dmnCoverage: z.ZodNumber;
|
||||||
|
unitCost: z.ZodObject<{
|
||||||
|
tokens: z.ZodNumber;
|
||||||
|
minutes: z.ZodNumber;
|
||||||
|
humanCalls: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
terminalMix: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export declare const reportSchema: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
period: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
title: z.ZodString;
|
||||||
|
window: z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
sections: z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
terminalLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
lines: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
metrics: z.ZodObject<{
|
||||||
|
autonomy: z.ZodNumber;
|
||||||
|
correction: z.ZodNumber;
|
||||||
|
effectiveAutonomy: z.ZodNumber;
|
||||||
|
interruption: z.ZodNumber;
|
||||||
|
breakpoint: z.ZodNumber;
|
||||||
|
dmnCoverage: z.ZodNumber;
|
||||||
|
unitCost: z.ZodObject<{
|
||||||
|
tokens: z.ZodNumber;
|
||||||
|
minutes: z.ZodNumber;
|
||||||
|
humanCalls: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
terminalMix: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
counts: z.ZodObject<{
|
||||||
|
touched: z.ZodNumber;
|
||||||
|
released: z.ZodNumber;
|
||||||
|
logLines: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
submittedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
generatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
export type ReportView = z.infer<typeof reportSchema>;
|
||||||
|
/** 未提交的草案每次取回都重算 —— 汇报是投影,不是账本 */
|
||||||
|
export declare const draftReportRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/reports/:period";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
period: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
period: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
title: z.ZodString;
|
||||||
|
window: z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
sections: z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
terminalLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
lines: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
metrics: z.ZodObject<{
|
||||||
|
autonomy: z.ZodNumber;
|
||||||
|
correction: z.ZodNumber;
|
||||||
|
effectiveAutonomy: z.ZodNumber;
|
||||||
|
interruption: z.ZodNumber;
|
||||||
|
breakpoint: z.ZodNumber;
|
||||||
|
dmnCoverage: z.ZodNumber;
|
||||||
|
unitCost: z.ZodObject<{
|
||||||
|
tokens: z.ZodNumber;
|
||||||
|
minutes: z.ZodNumber;
|
||||||
|
humanCalls: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
terminalMix: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
counts: z.ZodObject<{
|
||||||
|
touched: z.ZodNumber;
|
||||||
|
released: z.ZodNumber;
|
||||||
|
logLines: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
submittedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
generatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export declare const listReportsRoute: {
|
||||||
|
readonly method: "GET";
|
||||||
|
readonly path: "/api/reports";
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
reports: z.ZodArray<z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
period: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
title: z.ZodString;
|
||||||
|
window: z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
sections: z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
terminalLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
lines: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
metrics: z.ZodObject<{
|
||||||
|
autonomy: z.ZodNumber;
|
||||||
|
correction: z.ZodNumber;
|
||||||
|
effectiveAutonomy: z.ZodNumber;
|
||||||
|
interruption: z.ZodNumber;
|
||||||
|
breakpoint: z.ZodNumber;
|
||||||
|
dmnCoverage: z.ZodNumber;
|
||||||
|
unitCost: z.ZodObject<{
|
||||||
|
tokens: z.ZodNumber;
|
||||||
|
minutes: z.ZodNumber;
|
||||||
|
humanCalls: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
terminalMix: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
counts: z.ZodObject<{
|
||||||
|
touched: z.ZodNumber;
|
||||||
|
released: z.ZodNumber;
|
||||||
|
logLines: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
submittedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
generatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
/** 确认即提交,提交后不再改 */
|
||||||
|
export declare const submitReportRoute: {
|
||||||
|
readonly method: "POST";
|
||||||
|
readonly path: "/api/reports/:reportId/submit";
|
||||||
|
readonly params: z.ZodObject<{
|
||||||
|
reportId: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly body: z.ZodObject<{
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
readonly output: z.ZodObject<{
|
||||||
|
id: z.ZodString;
|
||||||
|
period: z.ZodEnum<{
|
||||||
|
day: "day";
|
||||||
|
week: "week";
|
||||||
|
month: "month";
|
||||||
|
year: "year";
|
||||||
|
}>;
|
||||||
|
title: z.ZodString;
|
||||||
|
window: z.ZodObject<{
|
||||||
|
from: z.ZodString;
|
||||||
|
to: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
sections: z.ZodArray<z.ZodObject<{
|
||||||
|
taskId: z.ZodString;
|
||||||
|
title: z.ZodString;
|
||||||
|
activityId: z.ZodString;
|
||||||
|
terminalLabel: z.ZodOptional<z.ZodString>;
|
||||||
|
lines: z.ZodArray<z.ZodString>;
|
||||||
|
}, z.core.$strip>>;
|
||||||
|
metrics: z.ZodObject<{
|
||||||
|
autonomy: z.ZodNumber;
|
||||||
|
correction: z.ZodNumber;
|
||||||
|
effectiveAutonomy: z.ZodNumber;
|
||||||
|
interruption: z.ZodNumber;
|
||||||
|
breakpoint: z.ZodNumber;
|
||||||
|
dmnCoverage: z.ZodNumber;
|
||||||
|
unitCost: z.ZodObject<{
|
||||||
|
tokens: z.ZodNumber;
|
||||||
|
minutes: z.ZodNumber;
|
||||||
|
humanCalls: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
terminalMix: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
counts: z.ZodObject<{
|
||||||
|
touched: z.ZodNumber;
|
||||||
|
released: z.ZodNumber;
|
||||||
|
logLines: z.ZodNumber;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
note: z.ZodOptional<z.ZodString>;
|
||||||
|
submittedAt: z.ZodOptional<z.ZodString>;
|
||||||
|
generatedAt: z.ZodString;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
};
|
||||||
|
export type ListMinutesOutput = z.infer<typeof listMinutesRoute.output>;
|
||||||
|
export type ListReportsOutput = z.infer<typeof listReportsRoute.output>;
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ import { createIntentStore, createReleaseService } from '@modules/release';
|
|||||||
import type { TaskService } from '@modules/task';
|
import type { TaskService } from '@modules/task';
|
||||||
import type { ProcessService } from '@modules/process';
|
import type { ProcessService } from '@modules/process';
|
||||||
import type { TriggerService } from '@modules/trigger';
|
import type { TriggerService } from '@modules/trigger';
|
||||||
|
import type { MinuteService } from '@modules/minute';
|
||||||
|
import type { ReportService } from '@modules/report';
|
||||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||||
import { wireEngines } from './wiring/engines.ts';
|
import { wireEngines } from './wiring/engines.ts';
|
||||||
|
import { wireDeskElements } from './wiring/desk-elements.ts';
|
||||||
import type { ChannelService } from '@modules/channel';
|
import type { ChannelService } from '@modules/channel';
|
||||||
import {
|
import {
|
||||||
createChannelRepository,
|
createChannelRepository,
|
||||||
@@ -92,6 +95,8 @@ export type Container = {
|
|||||||
readonly task: TaskService;
|
readonly task: TaskService;
|
||||||
readonly process: ProcessService;
|
readonly process: ProcessService;
|
||||||
readonly trigger: TriggerService;
|
readonly trigger: TriggerService;
|
||||||
|
readonly minute: MinuteService;
|
||||||
|
readonly report: ReportService;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -498,6 +503,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// L8:工作台后两个要素 —— 只读 memory 与 task 的投影,一行都不另记
|
||||||
|
const deskElements = wireDeskElements({
|
||||||
|
db,
|
||||||
|
memory,
|
||||||
|
task: engines.task,
|
||||||
|
org,
|
||||||
|
process: engines.process,
|
||||||
|
ids,
|
||||||
|
clock: systemClock,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
broadcaster: createBroadcaster(),
|
broadcaster: createBroadcaster(),
|
||||||
@@ -520,6 +536,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
|||||||
task: engines.task,
|
task: engines.task,
|
||||||
process: engines.process,
|
process: engines.process,
|
||||||
trigger: engines.trigger,
|
trigger: engines.trigger,
|
||||||
|
minute: deskElements.minute,
|
||||||
|
report: deskElements.report,
|
||||||
async close() {
|
async close() {
|
||||||
await pool.end();
|
await pool.end();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* 工作台后两个要素的路由:纪要与汇报(编码规范 §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 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 type { AppEnv } from '../http-env.ts';
|
||||||
|
|
||||||
|
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||||
|
async function me(account: string) {
|
||||||
|
return container.org.findByAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = await me(c.get('account'));
|
||||||
|
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 = await me(c.get('account'));
|
||||||
|
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 = await me(c.get('account'));
|
||||||
|
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 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 = await me(c.get('account'));
|
||||||
|
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 = await me(c.get('account'));
|
||||||
|
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 = await me(c.get('account'));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
|||||||
import type { Container } from './container.ts';
|
import type { Container } from './container.ts';
|
||||||
import type { AppEnv } from './http-env.ts';
|
import type { AppEnv } from './http-env.ts';
|
||||||
import { registerDeskRoutes } from './routes/desk.ts';
|
import { registerDeskRoutes } from './routes/desk.ts';
|
||||||
|
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||||
@@ -44,6 +45,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
|||||||
registerWorkbenchRoutes(app, container);
|
registerWorkbenchRoutes(app, container);
|
||||||
registerGovernanceRoutes(app, container);
|
registerGovernanceRoutes(app, container);
|
||||||
registerDeskRoutes(app, container);
|
registerDeskRoutes(app, container);
|
||||||
|
registerDeskElementRoutes(app, container);
|
||||||
registerGovernanceOpsRoutes(app, container);
|
registerGovernanceOpsRoutes(app, container);
|
||||||
registerIngestRoutes(app, container);
|
registerIngestRoutes(app, container);
|
||||||
registerStreamRoute(app, container.broadcaster);
|
registerStreamRoute(app, container.broadcaster);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 工作台后两个要素的装配:纪要(minute)与汇报(report)—— L8。
|
||||||
|
*
|
||||||
|
* 「五要素同源 —— 做事与留痕是同一个动作:五要素都是同一份过程事实
|
||||||
|
* (任务记忆与工作流事件)在员工视角下的投影。」(OS 2.1)
|
||||||
|
* 所以这里的 `FactSource` **只读** memory 与 task,一行都不另记。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { MemoryService } from '@modules/memory';
|
||||||
|
import type { MinuteService } from '@modules/minute';
|
||||||
|
import { createMinuteService, createMinuteStore } from '@modules/minute';
|
||||||
|
import type { OrgService } from '@modules/org';
|
||||||
|
import type { ProcessService } from '@modules/process';
|
||||||
|
import type { LogFact, ReportService, TaskFact } from '@modules/report';
|
||||||
|
import { createReportService, createReportStore } from '@modules/report';
|
||||||
|
import type { TaskService } from '@modules/task';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
|
||||||
|
export type DeskElementDeps = {
|
||||||
|
readonly db: Db;
|
||||||
|
readonly memory: MemoryService;
|
||||||
|
readonly task: TaskService;
|
||||||
|
readonly org: OrgService;
|
||||||
|
readonly process: ProcessService;
|
||||||
|
readonly ids: { next(): string };
|
||||||
|
readonly clock: { now(): Date };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wireDeskElements(deps: DeskElementDeps): { minute: MinuteService; report: ReportService } {
|
||||||
|
const minute = createMinuteService({
|
||||||
|
store: createMinuteStore(deps.db),
|
||||||
|
registration: {
|
||||||
|
// 登记触发:凡单必有来源 —— 纪要起单的来源就是登记人
|
||||||
|
start: async (input) => {
|
||||||
|
const started = await deps.process.start({
|
||||||
|
packageId: input.processPackageId,
|
||||||
|
subject: input.subject,
|
||||||
|
data: input.data,
|
||||||
|
source: { kind: 'registration', ref: input.processPackageId, by: input.byId },
|
||||||
|
});
|
||||||
|
return { workflowId: started.id };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ids: deps.ids,
|
||||||
|
clock: deps.clock,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 归侧日志行 → 度量事实。判断类与断点由日志标题的确定形态识别,不另设埋点 */
|
||||||
|
const toLogFact = (taskId: string, line: { at: string; side: LogFact['side']; title: string }): LogFact => ({
|
||||||
|
taskId,
|
||||||
|
at: line.at,
|
||||||
|
side: line.side,
|
||||||
|
title: line.title,
|
||||||
|
isHumanToolCall: line.title.startsWith('发出:'),
|
||||||
|
// DMN / 机检的确定性求值在日志里是「求值:」;LLM 的判断落在「调用:」
|
||||||
|
isDeterministicJudgement: line.title.startsWith('求值:'),
|
||||||
|
isJudgement: line.title.startsWith('求值:') || line.title.startsWith('调用:'),
|
||||||
|
isBreakpoint: line.title.includes('断点') || line.title.includes('交集为空'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = createReportService({
|
||||||
|
store: createReportStore(deps.db),
|
||||||
|
facts: {
|
||||||
|
async logsOf(staffId, from, to) {
|
||||||
|
const owners = staffId === undefined ? (await deps.org.listStaff()).staff.map((s) => s.id) : [staffId];
|
||||||
|
const nested = await Promise.all(owners.map((id) => deps.task.listByOwner(id)));
|
||||||
|
const tasks = nested.flat();
|
||||||
|
const perTask = await Promise.all(
|
||||||
|
tasks.map(async (t) => (await deps.memory.taskLog(t.id)).map((l) => toLogFact(t.id, l))),
|
||||||
|
);
|
||||||
|
return perTask.flat().filter((l) => l.at >= from && l.at < to);
|
||||||
|
},
|
||||||
|
async tasksOf(staffId) {
|
||||||
|
const owners = staffId === undefined ? (await deps.org.listStaff()).staff.map((s) => s.id) : [staffId];
|
||||||
|
const nested = await Promise.all(owners.map((id) => deps.task.listByOwner(id)));
|
||||||
|
return nested.flat().map<TaskFact>((t) => ({
|
||||||
|
taskId: t.id,
|
||||||
|
title: t.title,
|
||||||
|
activityId: t.activityId,
|
||||||
|
startedAt: t.startedAt,
|
||||||
|
finishedAt: t.finishedAt,
|
||||||
|
terminal: t.terminal,
|
||||||
|
// 提交批次 = 放行提交次数;放行完成必有一次提交,未终态的以已发生的放行计
|
||||||
|
submissions: t.terminal === 'released' ? 1 : 0,
|
||||||
|
// 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 —— 非放行终态即计一次
|
||||||
|
corrections: t.terminal !== undefined && t.terminal !== 'released' ? 1 : 0,
|
||||||
|
tokens: t.used.tokens,
|
||||||
|
activeMs: t.used.activeMs,
|
||||||
|
humanCalls: t.used.humanCalls,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ids: deps.ids,
|
||||||
|
clock: deps.clock,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { minute, report };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { StaffId };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# module: minute
|
||||||
|
|
||||||
|
职责: 纪要的沉淀与维护(团队主动上传);决议 → 登记触发起单
|
||||||
|
不负责: 起单本身的流转(见 process —— 本模块经 `RegistrationGateway` 端口调它)、
|
||||||
|
触发器配置(见 trigger —— 登记触发是三类触发之一,但纪要不配置触发器)
|
||||||
|
|
||||||
|
公开出口: index.ts
|
||||||
|
- 类型:`MinuteService` `Minute` `MinuteResolution` `ResolutionState`
|
||||||
|
`CreateMinuteInput` `RegisterInput`
|
||||||
|
- 错误:`MinuteNotFoundError` `ResolutionAlreadyRegisteredError`
|
||||||
|
- 端口类型:`MinuteStore` `RegistrationGateway`
|
||||||
|
- 用例工厂:`createMinuteService(deps): MinuteService`
|
||||||
|
- 纯函数:`newMinute` `addResolution` `markRegistered` `registeredWorkflowOf`
|
||||||
|
- adapter:`createMinuteStore`;Drizzle 表:`minutes` `minuteResolutions`
|
||||||
|
|
||||||
|
层级: L8(编码规范 §4)
|
||||||
|
依赖: @contracts;@shared/db;port:`RegistrationGateway`(process,L7,由 app 注入)
|
||||||
|
被依赖: app(路由组装)
|
||||||
|
|
||||||
|
不变量(OS 2.1 / 3.1):
|
||||||
|
- **纪要本身不产生责任**:没有负责人、没有终态、没有预算 —— 它是团队沉淀,不是任务
|
||||||
|
- **唯一通往责任的路是登记起单**:决议经登记触发调起流程包,「**登记人即来源**」
|
||||||
|
- **同一决议不重复起单**:已登记的决议不再迁移,由用例层抛
|
||||||
|
`ResolutionAlreadyRegisteredError` —— 起单一刻已有一条带来源的工作流在跑
|
||||||
|
- **只增不改**:正文可续、决议只增;纪要是沉淀,不是可覆写的文档
|
||||||
|
- 登记不可撤销:要否掉那条流,走治理台的控制事件,而不是回改纪要
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** 纪要领域:只增不改 + 决议登记不重复 —— domain 测试零 mock(OS 2.1 / 3.1) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { addResolution, markRegistered, newMinute, registeredWorkflowOf } from '../internal/domain/minute.ts';
|
||||||
|
|
||||||
|
const base = newMinute({
|
||||||
|
id: 'm1',
|
||||||
|
title: '7 月报价例会',
|
||||||
|
body: '讨论 Q3 报价口径',
|
||||||
|
authorId: 's-pm',
|
||||||
|
participants: ['s-pm', 's-bd'],
|
||||||
|
occurredOn: '2026-07-27',
|
||||||
|
resolutions: [{ id: 'r1', text: '统一 TYPE-B 的寻源时限为 3 天', ownerId: 's-prm' }],
|
||||||
|
at: '2026-07-27T02:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('纪要 —— 团队主动沉淀,本身不产生责任', () => {
|
||||||
|
it('新建的决议一律待登记', () => {
|
||||||
|
expect(base.resolutions.map((r) => r.state)).toEqual(['open']);
|
||||||
|
expect(registeredWorkflowOf(base, 'r1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('决议只增不改 —— 追加不动既有条目', () => {
|
||||||
|
const next = addResolution(base, { id: 'r2', text: '新增一条' }, '2026-07-27T03:00:00.000Z');
|
||||||
|
expect(next.resolutions.map((r) => r.id)).toEqual(['r1', 'r2']);
|
||||||
|
expect(next.resolutions[0]).toEqual(base.resolutions[0]);
|
||||||
|
expect(next.updatedAt).toBe('2026-07-27T03:00:00.000Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('决议 → 登记起单:登记人即来源', () => {
|
||||||
|
const at = '2026-07-27T04:00:00.000Z';
|
||||||
|
const registered = markRegistered(base, 'r1', { workflowId: 'wf-9', byId: 's-pm', at })!;
|
||||||
|
|
||||||
|
it('登记落笔记下工作流与登记人', () => {
|
||||||
|
expect(registered.resolutions[0]).toMatchObject({
|
||||||
|
state: 'registered',
|
||||||
|
workflowId: 'wf-9',
|
||||||
|
registeredById: 's-pm',
|
||||||
|
registeredAt: at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('已登记的决议可被查出它起的那条流 —— 用例层据此拒绝重复起单', () => {
|
||||||
|
expect(registeredWorkflowOf(registered, 'r1')).toBe('wf-9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不存在的决议返回 undefined,不静默改别的条目', () => {
|
||||||
|
expect(markRegistered(base, 'r-nope', { workflowId: 'wf-9', byId: 's-pm', at })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* module: minute —— 对外类型(编码规范 §10.1;语义出处 OS 2.1 / 3.1)。
|
||||||
|
*
|
||||||
|
* 纪要是工作台五要素里**唯一的主动动作**:团队主动维护上传的沉淀(会议纪要等)。
|
||||||
|
* 它本身不产生责任;责任的产生只有一条路——**决议经登记触发转为工作流的启动**,
|
||||||
|
* 「登记人即来源」。所以本模块的写面很薄,重头在「决议 → 登记起单」这一跳。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Minute, MinuteResolution, ResolutionState } from './internal/domain/minute.ts';
|
||||||
|
|
||||||
|
export type { Minute, MinuteResolution, ResolutionState };
|
||||||
|
|
||||||
|
export class MinuteNotFoundError extends Error {
|
||||||
|
constructor(readonly minuteId: string) {
|
||||||
|
super(`纪要不存在:${minuteId}`);
|
||||||
|
this.name = 'MinuteNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResolutionAlreadyRegisteredError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly resolutionId: string,
|
||||||
|
readonly workflowId: string,
|
||||||
|
) {
|
||||||
|
super(`决议 ${resolutionId} 已登记起单(工作流 ${workflowId})—— 同一决议不重复起单`);
|
||||||
|
this.name = 'ResolutionAlreadyRegisteredError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateMinuteInput = {
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
readonly authorId: StaffId;
|
||||||
|
/** 与会/相关人 —— 仅作沉淀标注,不产生责任 */
|
||||||
|
readonly participants?: readonly StaffId[];
|
||||||
|
readonly occurredOn: string;
|
||||||
|
readonly resolutions?: ReadonlyArray<{ text: string; ownerId?: StaffId | undefined }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RegisterInput = {
|
||||||
|
readonly resolutionId: string;
|
||||||
|
/** 登记人 —— 凡单必有来源,登记触发的来源就是他 */
|
||||||
|
readonly byId: StaffId;
|
||||||
|
readonly processPackageId: string;
|
||||||
|
/** 按调用接口 schema 填写的输入(表单由 schema 自动渲染) */
|
||||||
|
readonly data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinuteService = {
|
||||||
|
list(limit?: number): Promise<Minute[]>;
|
||||||
|
get(minuteId: string): Promise<Minute>;
|
||||||
|
create(input: CreateMinuteInput): Promise<Minute>;
|
||||||
|
/** 只增不改的编辑:正文可续,决议只增 —— 纪要是沉淀,不是可覆写的文档 */
|
||||||
|
appendResolution(minuteId: string, text: string, ownerId?: StaffId): Promise<Minute>;
|
||||||
|
|
||||||
|
/** 决议 → 登记起单:登记人即来源;同一决议只起一次单 */
|
||||||
|
register(input: RegisterInput): Promise<{ workflowId: string; resolutionId: string }>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** module: minute —— 唯一出口(编码规范 §10.1):纯再导出,不含实现。 */
|
||||||
|
export type {
|
||||||
|
CreateMinuteInput,
|
||||||
|
Minute,
|
||||||
|
MinuteResolution,
|
||||||
|
MinuteService,
|
||||||
|
RegisterInput,
|
||||||
|
ResolutionState,
|
||||||
|
} from './contract.ts';
|
||||||
|
export { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from './contract.ts';
|
||||||
|
export { createMinuteService } from './internal/usecase/minute-service.ts';
|
||||||
|
export { createMinuteStore } from './internal/adapter/minute-store.ts';
|
||||||
|
export { minutes, minuteResolutions } from './internal/adapter/minute-schema.ts';
|
||||||
|
export { newMinute, addResolution, markRegistered } from './internal/domain/minute.ts';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* minute 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||||
|
* 纪要是团队沉淀,存系统自持存储;决议单列一表 —— 它有状态(待登记 / 已登记),正文没有。
|
||||||
|
*/
|
||||||
|
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { sys } from '@shared/db.ts';
|
||||||
|
|
||||||
|
export const minutes = sys.table(
|
||||||
|
'minute',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
body: text('body').notNull(),
|
||||||
|
authorId: text('author_id').notNull(),
|
||||||
|
participants: jsonb('participants').notNull().default([]),
|
||||||
|
occurredOn: text('occurred_on').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('minute_occurred_idx').on(t.occurredOn)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const minuteResolutions = sys.table(
|
||||||
|
'minute_resolution',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
minuteId: text('minute_id').notNull(),
|
||||||
|
text: text('text').notNull(),
|
||||||
|
ownerId: text('owner_id'),
|
||||||
|
/** open | registered —— 登记不可撤销:起单一刻已有一条工作流在跑 */
|
||||||
|
state: text('state').notNull().default('open'),
|
||||||
|
workflowId: text('workflow_id'),
|
||||||
|
registeredById: text('registered_by_id'),
|
||||||
|
registeredAt: timestamp('registered_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(t) => [index('minute_resolution_minute_idx').on(t.minuteId)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/** minute 的持久化(编码规范 §10.2:框架只许出现在 adapter)。 */
|
||||||
|
import { asc, desc, eq, inArray } from 'drizzle-orm';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import type { Minute, MinuteResolution, ResolutionState } from '../domain/minute.ts';
|
||||||
|
import type { MinuteStore } from '../../port.ts';
|
||||||
|
import { minuteResolutions, minutes } from './minute-schema.ts';
|
||||||
|
|
||||||
|
type MinuteRow = typeof minutes.$inferSelect;
|
||||||
|
type ResolutionRow = typeof minuteResolutions.$inferSelect;
|
||||||
|
|
||||||
|
const toResolution = (row: ResolutionRow): MinuteResolution => ({
|
||||||
|
id: row.id,
|
||||||
|
text: row.text,
|
||||||
|
ownerId: row.ownerId ?? undefined,
|
||||||
|
state: row.state as ResolutionState,
|
||||||
|
workflowId: row.workflowId ?? undefined,
|
||||||
|
registeredById: row.registeredById ?? undefined,
|
||||||
|
registeredAt: row.registeredAt?.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toMinute = (row: MinuteRow, resolutions: readonly ResolutionRow[]): Minute => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
body: row.body,
|
||||||
|
authorId: row.authorId,
|
||||||
|
participants: row.participants as string[],
|
||||||
|
occurredOn: row.occurredOn,
|
||||||
|
resolutions: resolutions.map(toResolution),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createMinuteStore(db: Db): MinuteStore {
|
||||||
|
async function loadResolutions(minuteIds: readonly string[]): Promise<Map<string, ResolutionRow[]>> {
|
||||||
|
if (minuteIds.length === 0) return new Map();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(minuteResolutions)
|
||||||
|
.where(inArray(minuteResolutions.minuteId, [...minuteIds]))
|
||||||
|
.orderBy(asc(minuteResolutions.id));
|
||||||
|
const byMinute = new Map<string, ResolutionRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byMinute.get(row.minuteId) ?? [];
|
||||||
|
list.push(row);
|
||||||
|
byMinute.set(row.minuteId, list);
|
||||||
|
}
|
||||||
|
return byMinute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async list(limit) {
|
||||||
|
const rows = await db.select().from(minutes).orderBy(desc(minutes.occurredOn)).limit(limit);
|
||||||
|
const byMinute = await loadResolutions(rows.map((r) => r.id));
|
||||||
|
return rows.map((row) => toMinute(row, byMinute.get(row.id) ?? []));
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(minuteId) {
|
||||||
|
const [row] = await db.select().from(minutes).where(eq(minutes.id, minuteId)).limit(1);
|
||||||
|
if (row === undefined) return undefined;
|
||||||
|
const byMinute = await loadResolutions([minuteId]);
|
||||||
|
return toMinute(row, byMinute.get(minuteId) ?? []);
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(minute) {
|
||||||
|
// 纪要与其决议同事务落笔:决议的登记状态不得与纪要正文脱节
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.insert(minutes)
|
||||||
|
.values({
|
||||||
|
id: minute.id,
|
||||||
|
title: minute.title,
|
||||||
|
body: minute.body,
|
||||||
|
authorId: minute.authorId,
|
||||||
|
participants: [...minute.participants],
|
||||||
|
occurredOn: minute.occurredOn,
|
||||||
|
createdAt: new Date(minute.createdAt),
|
||||||
|
updatedAt: new Date(minute.updatedAt),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: minutes.id,
|
||||||
|
set: { title: minute.title, body: minute.body, updatedAt: new Date(minute.updatedAt) },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const r of minute.resolutions) {
|
||||||
|
await tx
|
||||||
|
.insert(minuteResolutions)
|
||||||
|
.values({
|
||||||
|
id: r.id,
|
||||||
|
minuteId: minute.id,
|
||||||
|
text: r.text,
|
||||||
|
ownerId: r.ownerId ?? null,
|
||||||
|
state: r.state,
|
||||||
|
workflowId: r.workflowId ?? null,
|
||||||
|
registeredById: r.registeredById ?? null,
|
||||||
|
registeredAt: r.registeredAt === undefined ? null : new Date(r.registeredAt),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: minuteResolutions.id,
|
||||||
|
set: {
|
||||||
|
state: r.state,
|
||||||
|
workflowId: r.workflowId ?? null,
|
||||||
|
registeredById: r.registeredById ?? null,
|
||||||
|
registeredAt: r.registeredAt === undefined ? null : new Date(r.registeredAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 纪要的领域形状与状态迁移 —— 零 IO、零框架(编码规范 §10.2)。
|
||||||
|
*
|
||||||
|
* 纪要是**团队主动沉淀**:不产生责任、没有终态、没有预算。
|
||||||
|
* 唯一有状态的是它的决议:待登记 → 已登记(起单)。登记不可撤销,
|
||||||
|
* 因为起单一刻就已经有一条工作流带着自己的来源在跑了。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ResolutionState = 'open' | 'registered';
|
||||||
|
|
||||||
|
export type MinuteResolution = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly text: string;
|
||||||
|
/** 决议的建议责任人 —— 仅是纪要里的记载,不是任务负责人 */
|
||||||
|
readonly ownerId?: string | undefined;
|
||||||
|
readonly state: ResolutionState;
|
||||||
|
/** 已登记时:它起出来的那条工作流 */
|
||||||
|
readonly workflowId?: string | undefined;
|
||||||
|
readonly registeredById?: string | undefined;
|
||||||
|
readonly registeredAt?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Minute = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
readonly authorId: string;
|
||||||
|
readonly participants: readonly string[];
|
||||||
|
/** 会议发生日(业务日期),与 createdAt 不是一回事 */
|
||||||
|
readonly occurredOn: string;
|
||||||
|
readonly resolutions: readonly MinuteResolution[];
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function newMinute(input: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
authorId: string;
|
||||||
|
participants: readonly string[];
|
||||||
|
occurredOn: string;
|
||||||
|
resolutions: ReadonlyArray<{ id: string; text: string; ownerId?: string | undefined }>;
|
||||||
|
at: string;
|
||||||
|
}): Minute {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
authorId: input.authorId,
|
||||||
|
participants: [...input.participants],
|
||||||
|
occurredOn: input.occurredOn,
|
||||||
|
resolutions: input.resolutions.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
text: r.text,
|
||||||
|
ownerId: r.ownerId,
|
||||||
|
state: 'open' as const,
|
||||||
|
})),
|
||||||
|
createdAt: input.at,
|
||||||
|
updatedAt: input.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 决议只增不改 —— 纪要是沉淀,不是可覆写的文档 */
|
||||||
|
export function addResolution(
|
||||||
|
minute: Minute,
|
||||||
|
resolution: { id: string; text: string; ownerId?: string | undefined },
|
||||||
|
at: string,
|
||||||
|
): Minute {
|
||||||
|
return {
|
||||||
|
...minute,
|
||||||
|
resolutions: [...minute.resolutions, { ...resolution, state: 'open' }],
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登记起单的状态落笔。返回 `undefined` 表示该决议不在此纪要里;
|
||||||
|
* 已登记的决议**不迁移**,由用例层抛「不重复起单」。
|
||||||
|
*/
|
||||||
|
export function markRegistered(
|
||||||
|
minute: Minute,
|
||||||
|
resolutionId: string,
|
||||||
|
mark: { workflowId: string; byId: string; at: string },
|
||||||
|
): Minute | undefined {
|
||||||
|
const target = minute.resolutions.find((r) => r.id === resolutionId);
|
||||||
|
if (target === undefined) return undefined;
|
||||||
|
return {
|
||||||
|
...minute,
|
||||||
|
resolutions: minute.resolutions.map((r) =>
|
||||||
|
r.id === resolutionId
|
||||||
|
? {
|
||||||
|
...r,
|
||||||
|
state: 'registered' as const,
|
||||||
|
workflowId: mark.workflowId,
|
||||||
|
registeredById: mark.byId,
|
||||||
|
registeredAt: mark.at,
|
||||||
|
}
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
updatedAt: mark.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已登记的决议 —— 用例层据此拒绝重复起单 */
|
||||||
|
export function registeredWorkflowOf(minute: Minute, resolutionId: string): string | undefined {
|
||||||
|
const target = minute.resolutions.find((r) => r.id === resolutionId);
|
||||||
|
return target?.state === 'registered' ? target.workflowId : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 纪要用例(编码规范 §10.2)。
|
||||||
|
*
|
||||||
|
* 「决议可经**登记触发**转为工作流的启动 —— 登记人即来源。」(OS 2.1 / 3.1)
|
||||||
|
* 这是纪要与责任之间**唯一**的通路:纪要本身不产生责任。
|
||||||
|
*/
|
||||||
|
import type { MinuteService } from '../../contract.ts';
|
||||||
|
import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '../../contract.ts';
|
||||||
|
import type { MinuteDeps } from '../../port.ts';
|
||||||
|
import { addResolution, markRegistered, newMinute, registeredWorkflowOf } from '../domain/minute.ts';
|
||||||
|
|
||||||
|
export function createMinuteService(deps: MinuteDeps): MinuteService {
|
||||||
|
async function require(minuteId: string) {
|
||||||
|
const minute = await deps.store.get(minuteId);
|
||||||
|
if (minute === undefined) throw new MinuteNotFoundError(minuteId);
|
||||||
|
return minute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: (limit = 50) => deps.store.list(limit),
|
||||||
|
get: (minuteId) => require(minuteId),
|
||||||
|
|
||||||
|
async create(input) {
|
||||||
|
const minute = newMinute({
|
||||||
|
id: deps.ids.next(),
|
||||||
|
title: input.title,
|
||||||
|
body: input.body,
|
||||||
|
authorId: input.authorId,
|
||||||
|
participants: input.participants ?? [],
|
||||||
|
occurredOn: input.occurredOn,
|
||||||
|
resolutions: (input.resolutions ?? []).map((r) => ({
|
||||||
|
id: deps.ids.next(),
|
||||||
|
text: r.text,
|
||||||
|
ownerId: r.ownerId,
|
||||||
|
})),
|
||||||
|
at: deps.clock.now().toISOString(),
|
||||||
|
});
|
||||||
|
await deps.store.save(minute);
|
||||||
|
return minute;
|
||||||
|
},
|
||||||
|
|
||||||
|
async appendResolution(minuteId, text, ownerId) {
|
||||||
|
const minute = await require(minuteId);
|
||||||
|
const next = addResolution(
|
||||||
|
minute,
|
||||||
|
{ id: deps.ids.next(), text, ownerId },
|
||||||
|
deps.clock.now().toISOString(),
|
||||||
|
);
|
||||||
|
await deps.store.save(next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(input) {
|
||||||
|
const all = await deps.store.list(200);
|
||||||
|
const minute = all.find((m) => m.resolutions.some((r) => r.id === input.resolutionId));
|
||||||
|
if (minute === undefined) throw new MinuteNotFoundError(input.resolutionId);
|
||||||
|
|
||||||
|
// 同一决议不重复起单:起单一刻就已经有一条带来源的工作流在跑了
|
||||||
|
const already = registeredWorkflowOf(minute, input.resolutionId);
|
||||||
|
if (already !== undefined) throw new ResolutionAlreadyRegisteredError(input.resolutionId, already);
|
||||||
|
|
||||||
|
const resolution = minute.resolutions.find((r) => r.id === input.resolutionId)!;
|
||||||
|
const started = await deps.registration.start({
|
||||||
|
processPackageId: input.processPackageId,
|
||||||
|
subject: resolution.text,
|
||||||
|
data: input.data,
|
||||||
|
byId: input.byId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const next = markRegistered(minute, input.resolutionId, {
|
||||||
|
workflowId: started.workflowId,
|
||||||
|
byId: input.byId,
|
||||||
|
at: deps.clock.now().toISOString(),
|
||||||
|
})!;
|
||||||
|
await deps.store.save(next);
|
||||||
|
return { workflowId: started.workflowId, resolutionId: input.resolutionId };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "@modules/minute", "private": true, "type": "module", "main": "index.ts" }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* module: minute 的外部所需(编码规范 §10.1)。
|
||||||
|
* 「port 声明本模块需要外界提供什么」—— 由 app 层注入,模块自己不认实现。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Minute } from './internal/domain/minute.ts';
|
||||||
|
|
||||||
|
export type MinuteStore = {
|
||||||
|
list(limit: number): Promise<Minute[]>;
|
||||||
|
get(minuteId: string): Promise<Minute | undefined>;
|
||||||
|
save(minute: Minute): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 登记起单 —— app 注入 `process`(L7):经调用接口调起流程包,不碰流程内部 */
|
||||||
|
export type RegistrationGateway = {
|
||||||
|
start(input: {
|
||||||
|
processPackageId: string;
|
||||||
|
subject: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
/** 来源:登记人 */
|
||||||
|
byId: StaffId;
|
||||||
|
}): Promise<{ workflowId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MinuteDeps = {
|
||||||
|
readonly store: MinuteStore;
|
||||||
|
readonly registration: RegistrationGateway;
|
||||||
|
readonly ids: { next(): string };
|
||||||
|
readonly clock: { now(): Date };
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# module: report
|
||||||
|
|
||||||
|
职责: 汇报(日/周/月/年)对任务日志的聚合投影;度量八指标的计算
|
||||||
|
不负责: 任务日志本身(见 memory)、终态与预算(见 task)—— 本模块**只读不写**这两处;
|
||||||
|
活动记忆与蒸馏(见 memory)
|
||||||
|
|
||||||
|
公开出口: index.ts
|
||||||
|
- 类型:`ReportService` `Report` `ReportDraft` `ReportSection` `ReportPeriod`
|
||||||
|
`Window` `Metrics` `LogFact` `TaskFact`
|
||||||
|
- 错误:`ReportNotFoundError` `ReportAlreadySubmittedError`
|
||||||
|
- 端口类型:`ReportStore` `FactSource`
|
||||||
|
- 用例工厂:`createReportService(deps): ReportService`
|
||||||
|
- 纯函数:`windowOf` `periodLabel` `draftReport` `computeMetrics`;常量 `REPORT_PERIODS`
|
||||||
|
- adapter:`createReportStore`;Drizzle 表:`reports`
|
||||||
|
|
||||||
|
层级: L8(编码规范 §4)
|
||||||
|
依赖: @contracts;@shared/db;port:`FactSource`(memory L2 + task L6,由 app 注入)
|
||||||
|
被依赖: app(路由组装)
|
||||||
|
|
||||||
|
不变量(OS 2.1 / 8.1 / 8.3):
|
||||||
|
- **零手工汇报**:草案的每一句都取自任务日志;员工唯一的手工位是确认前补的一段话
|
||||||
|
- **汇报是投影不是账本**:未提交的草案每次取回都重算;**提交那一刻**才固定为事实快照
|
||||||
|
- **确认即提交,提交后不再改**(`ReportAlreadySubmittedError`)
|
||||||
|
- **口径挂归侧与事件流,零埋点**:输入只有归侧日志行与任务终态,没有第三种埋点
|
||||||
|
- **自主率与纠正率成对读**,复合为有效自主率 —— 单看任一都会被优化坏
|
||||||
|
- **单位成本的分母只认放行通过**:跑得多不等于产出多
|
||||||
|
- 度量是**只读投影**,不设独立模块(§4 注);治理台记忆区与工作台汇报同源同口径
|
||||||
|
- 周期窗口左闭右开、**周一为首日**、按 UTC 切分(单组织单时区)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/** 汇报周期与聚合 —— domain 测试零 mock(OS 2.1 / 8.3) */
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { periodLabel, windowOf } from '../internal/domain/period.ts';
|
||||||
|
import { computeMetrics, draftReport } from '../internal/domain/aggregate.ts';
|
||||||
|
import type { LogFact, TaskFact } from '../internal/domain/aggregate.ts';
|
||||||
|
|
||||||
|
const log = (over: Partial<LogFact> = {}): LogFact => ({
|
||||||
|
taskId: 't1',
|
||||||
|
at: '2026-07-27T10:00:00.000Z',
|
||||||
|
side: 'machine',
|
||||||
|
title: '调用:某步',
|
||||||
|
isHumanToolCall: false,
|
||||||
|
isDeterministicJudgement: false,
|
||||||
|
isJudgement: false,
|
||||||
|
isBreakpoint: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = (over: Partial<TaskFact> = {}): TaskFact => ({
|
||||||
|
taskId: 't1',
|
||||||
|
title: 'A1 RFQ需求分析',
|
||||||
|
activityId: 'quote-intake',
|
||||||
|
startedAt: '2026-07-27T09:00:00.000Z',
|
||||||
|
submissions: 0,
|
||||||
|
corrections: 0,
|
||||||
|
tokens: 0,
|
||||||
|
activeMs: 0,
|
||||||
|
humanCalls: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('周期窗口 —— 左闭右开,周一为首日', () => {
|
||||||
|
it('日窗口是当天 00:00 起 24 小时', () => {
|
||||||
|
expect(windowOf('day', new Date('2026-07-27T15:30:00Z'))).toEqual({
|
||||||
|
from: '2026-07-27T00:00:00.000Z',
|
||||||
|
to: '2026-07-28T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('周窗口自周一起算 —— 周日属于它前面那一周', () => {
|
||||||
|
// 2026-07-27 是周一;2026-08-02 是周日,应落在同一周
|
||||||
|
const monday = windowOf('week', new Date('2026-07-27T00:00:00Z'));
|
||||||
|
const sunday = windowOf('week', new Date('2026-08-02T23:59:00Z'));
|
||||||
|
expect(monday.from).toBe('2026-07-27T00:00:00.000Z');
|
||||||
|
expect(sunday).toEqual(monday);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('月与年按自然边界切', () => {
|
||||||
|
expect(windowOf('month', new Date('2026-07-27T00:00:00Z')).to).toBe('2026-08-01T00:00:00.000Z');
|
||||||
|
expect(windowOf('year', new Date('2026-07-27T00:00:00Z'))).toEqual({
|
||||||
|
from: '2026-01-01T00:00:00.000Z',
|
||||||
|
to: '2027-01-01T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('周期名可读', () => {
|
||||||
|
expect(periodLabel('day', new Date('2026-07-27T00:00:00Z'))).toBe('2026-07-27 日报');
|
||||||
|
expect(periodLabel('month', new Date('2026-07-27T00:00:00Z'))).toBe('2026-07 月报');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('度量八指标 —— 口径挂归侧,零埋点', () => {
|
||||||
|
it('自主率不把裁决与等待计入分母', () => {
|
||||||
|
const m = computeMetrics(
|
||||||
|
[log({ side: 'machine' }), log({ side: 'machine' }), log({ side: 'human' }), log({ side: 'human-verdict' }), log({ side: 'world-wait' })],
|
||||||
|
[task()],
|
||||||
|
);
|
||||||
|
expect(m.autonomy).toBeCloseTo(2 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有效自主率 = 自主率 ×(1 − 纠正率)—— 猛冲后返工不占便宜', () => {
|
||||||
|
const m = computeMetrics(
|
||||||
|
[log({ side: 'machine' }), log({ side: 'machine' }), log({ side: 'machine' }), log({ side: 'human' })],
|
||||||
|
[task({ submissions: 4, corrections: 1 })],
|
||||||
|
);
|
||||||
|
expect(m.autonomy).toBeCloseTo(0.75);
|
||||||
|
expect(m.correction).toBeCloseTo(0.25);
|
||||||
|
expect(m.effectiveAutonomy).toBeCloseTo(0.5625);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('单位成本的分母只认放行通过 —— 跑得多不等于产出多', () => {
|
||||||
|
const m = computeMetrics(
|
||||||
|
[log()],
|
||||||
|
[
|
||||||
|
task({ taskId: 't1', terminal: 'released', tokens: 1000, activeMs: 120_000, humanCalls: 2 }),
|
||||||
|
task({ taskId: 't2', terminal: 'terminated', tokens: 3000, activeMs: 60_000, humanCalls: 4 }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(m.unitCost.tokens).toBe(4000);
|
||||||
|
expect(m.unitCost.minutes).toBeCloseTo(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无提交批次时纠正率为 0,不产生 NaN', () => {
|
||||||
|
const m = computeMetrics([], []);
|
||||||
|
expect(m.autonomy).toBe(0);
|
||||||
|
expect(m.correction).toBe(0);
|
||||||
|
expect(m.effectiveAutonomy).toBe(0);
|
||||||
|
expect(m.unitCost.tokens).toBe(0);
|
||||||
|
expect(Object.values(m.terminalMix).every((v) => v === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('终态分布覆盖五个终态名', () => {
|
||||||
|
const m = computeMetrics([], [task({ terminal: 'released' }), task({ taskId: 't2', terminal: 'blocked' })]);
|
||||||
|
expect(Object.keys(m.terminalMix).sort()).toEqual(
|
||||||
|
['blocked', 'exhausted', 'released', 'stalled', 'terminated'],
|
||||||
|
);
|
||||||
|
expect(m.terminalMix.released).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('汇报草案 —— 由任务日志聚合,员工不写', () => {
|
||||||
|
const window = windowOf('day', new Date('2026-07-27T12:00:00Z'));
|
||||||
|
|
||||||
|
it('只收窗口内的日志,窗口外的任务不成段', () => {
|
||||||
|
const draft = draftReport(
|
||||||
|
[
|
||||||
|
log({ taskId: 't1', at: '2026-07-27T10:00:00.000Z', title: '调用:步 1' }),
|
||||||
|
log({ taskId: 't2', at: '2026-07-26T10:00:00.000Z', title: '昨天的事' }),
|
||||||
|
],
|
||||||
|
[task({ taskId: 't1' }), task({ taskId: 't2', title: '昨天那单' })],
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
expect(draft.sections.map((s) => s.taskId)).toEqual(['t1']);
|
||||||
|
expect(draft.counts.logLines).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('段内按时间排序,每一句都取自任务日志', () => {
|
||||||
|
const draft = draftReport(
|
||||||
|
[
|
||||||
|
log({ at: '2026-07-27T12:00:00.000Z', title: '返回:步 2' }),
|
||||||
|
log({ at: '2026-07-27T10:00:00.000Z', title: '调用:步 1' }),
|
||||||
|
],
|
||||||
|
[task()],
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
expect(draft.sections[0]?.lines).toEqual(['调用:步 1', '返回:步 2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('终态以中文名标注', () => {
|
||||||
|
const draft = draftReport([log()], [task({ terminal: 'released' })], window);
|
||||||
|
expect(draft.sections[0]?.terminalLabel).toBe('放行完成');
|
||||||
|
expect(draft.counts.released).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* module: report —— 对外类型(编码规范 §10.1;语义出处 OS 2.1 / 8.1 / 8.3)。
|
||||||
|
*
|
||||||
|
* 汇报是**系统代劳的自动产出**:由本人各任务的任务日志聚合生成,确认即提交,零手工汇报。
|
||||||
|
* 度量八指标是任务日志与终态之上的**只读投影**,不设独立模块(§4 注)。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Metrics, ReportDraft, ReportSection } from './internal/domain/aggregate.ts';
|
||||||
|
import type { ReportPeriod, Window } from './internal/domain/period.ts';
|
||||||
|
|
||||||
|
export type { Metrics, ReportDraft, ReportPeriod, ReportSection, Window };
|
||||||
|
|
||||||
|
export class ReportNotFoundError extends Error {
|
||||||
|
constructor(readonly reportId: string) {
|
||||||
|
super(`汇报不存在:${reportId}`);
|
||||||
|
this.name = 'ReportNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportAlreadySubmittedError extends Error {
|
||||||
|
constructor(readonly reportId: string) {
|
||||||
|
super(`汇报 ${reportId} 已提交 —— 确认即提交,提交后不再改`);
|
||||||
|
this.name = 'ReportAlreadySubmittedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇报单:草案由系统生成,员工确认即提交 */
|
||||||
|
export type Report = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly staffId: StaffId;
|
||||||
|
readonly period: ReportPeriod;
|
||||||
|
readonly title: string;
|
||||||
|
readonly window: Window;
|
||||||
|
readonly draft: ReportDraft;
|
||||||
|
/** 员工可在确认前补一段话 —— 唯一的手工位 */
|
||||||
|
readonly note?: string | undefined;
|
||||||
|
readonly submittedAt?: string | undefined;
|
||||||
|
readonly generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportService = {
|
||||||
|
/** 生成(或取回)某人某周期的汇报草案 —— 未提交的重取即重算,提交后固定 */
|
||||||
|
draft(staffId: StaffId, period: ReportPeriod, anchor?: Date): Promise<Report>;
|
||||||
|
list(staffId: StaffId, limit?: number): Promise<Report[]>;
|
||||||
|
get(reportId: string): Promise<Report>;
|
||||||
|
/** 确认即提交 */
|
||||||
|
submit(reportId: string, note?: string): Promise<Report>;
|
||||||
|
/** 度量八指标 —— 治理台记忆区的仪表读数,同源同口径 */
|
||||||
|
metrics(staffId: StaffId | undefined, period: ReportPeriod, anchor?: Date): Promise<Metrics>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** module: report —— 唯一出口(编码规范 §10.1):纯再导出,不含实现。 */
|
||||||
|
export type { Metrics, Report, ReportDraft, ReportPeriod, ReportSection, ReportService, Window } from './contract.ts';
|
||||||
|
export { ReportAlreadySubmittedError, ReportNotFoundError } from './contract.ts';
|
||||||
|
export type { FactSource, ReportStore, ReportDeps } from './port.ts';
|
||||||
|
export type { LogFact, TaskFact } from './internal/domain/aggregate.ts';
|
||||||
|
export { computeMetrics, draftReport } from './internal/domain/aggregate.ts';
|
||||||
|
export { REPORT_PERIODS, periodLabel, windowOf } from './internal/domain/period.ts';
|
||||||
|
export { createReportService } from './internal/usecase/report-service.ts';
|
||||||
|
export { createReportStore } from './internal/adapter/report-store.ts';
|
||||||
|
export { reports } from './internal/adapter/report-schema.ts';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* report 的 sys schema 表(编码规范 §7 / ADR-002)。
|
||||||
|
* 只存**汇报单**本身;草案内容是投影的快照 —— 未提交时可重算,提交后固定为当时事实。
|
||||||
|
*/
|
||||||
|
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
|
||||||
|
import { sys } from '@shared/db.ts';
|
||||||
|
|
||||||
|
export const reports = sys.table(
|
||||||
|
'report',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
staffId: text('staff_id').notNull(),
|
||||||
|
period: text('period').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
windowFrom: text('window_from').notNull(),
|
||||||
|
windowTo: text('window_to').notNull(),
|
||||||
|
draft: jsonb('draft').notNull(),
|
||||||
|
note: text('note'),
|
||||||
|
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
||||||
|
generatedAt: timestamp('generated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [index('report_staff_period_idx').on(t.staffId, t.period, t.windowFrom)],
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/** report 的持久化(编码规范 §10.2:框架只许出现在 adapter)。 */
|
||||||
|
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Db } from '@shared/db.ts';
|
||||||
|
import type { Report, ReportDraft, ReportPeriod } from '../../contract.ts';
|
||||||
|
import type { ReportStore } from '../../port.ts';
|
||||||
|
import { reports } from './report-schema.ts';
|
||||||
|
|
||||||
|
type Row = typeof reports.$inferSelect;
|
||||||
|
|
||||||
|
const toReport = (row: Row): Report => ({
|
||||||
|
id: row.id,
|
||||||
|
staffId: row.staffId as StaffId,
|
||||||
|
period: row.period as ReportPeriod,
|
||||||
|
title: row.title,
|
||||||
|
window: { from: row.windowFrom, to: row.windowTo },
|
||||||
|
draft: row.draft as ReportDraft,
|
||||||
|
note: row.note ?? undefined,
|
||||||
|
submittedAt: row.submittedAt?.toISOString(),
|
||||||
|
generatedAt: row.generatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createReportStore(db: Db): ReportStore {
|
||||||
|
return {
|
||||||
|
async list(staffId, limit) {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(reports)
|
||||||
|
.where(eq(reports.staffId, staffId))
|
||||||
|
.orderBy(desc(reports.windowFrom))
|
||||||
|
.limit(limit);
|
||||||
|
return rows.map(toReport);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(reportId) {
|
||||||
|
const [row] = await db.select().from(reports).where(eq(reports.id, reportId)).limit(1);
|
||||||
|
return row === undefined ? undefined : toReport(row);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 未提交的同窗汇报可重算复用;已提交的不再复用,它已是当时的事实快照 */
|
||||||
|
async findOpen(staffId, period, windowFrom) {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(reports)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reports.staffId, staffId),
|
||||||
|
eq(reports.period, period),
|
||||||
|
eq(reports.windowFrom, windowFrom),
|
||||||
|
isNull(reports.submittedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row === undefined ? undefined : toReport(row);
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(report) {
|
||||||
|
await db
|
||||||
|
.insert(reports)
|
||||||
|
.values({
|
||||||
|
id: report.id,
|
||||||
|
staffId: report.staffId,
|
||||||
|
period: report.period,
|
||||||
|
title: report.title,
|
||||||
|
windowFrom: report.window.from,
|
||||||
|
windowTo: report.window.to,
|
||||||
|
draft: report.draft,
|
||||||
|
note: report.note ?? null,
|
||||||
|
submittedAt: report.submittedAt === undefined ? null : new Date(report.submittedAt),
|
||||||
|
generatedAt: new Date(report.generatedAt),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: reports.id,
|
||||||
|
set: {
|
||||||
|
draft: report.draft,
|
||||||
|
note: report.note ?? null,
|
||||||
|
submittedAt: report.submittedAt === undefined ? null : new Date(report.submittedAt),
|
||||||
|
generatedAt: new Date(report.generatedAt),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* 汇报聚合与度量八指标 —— 纯投影(编码规范 §10.2;语义出处 OS 8.1 / 8.3)。
|
||||||
|
*
|
||||||
|
* 「口径全部挂在**归侧标签与事件流**上——零埋点,统计即查询。」
|
||||||
|
* 所以这里的输入只有两样:归侧过的日志行、任务的终态与纠正信号。没有第三种埋点。
|
||||||
|
*/
|
||||||
|
import type { AttributionSide, TerminalState } from '@contracts';
|
||||||
|
import { TERMINAL_STATE_LABEL } from '@contracts';
|
||||||
|
import type { Window } from './period.ts';
|
||||||
|
import { withinWindow } from './period.ts';
|
||||||
|
|
||||||
|
export type LogFact = {
|
||||||
|
readonly taskId: string;
|
||||||
|
readonly at: string;
|
||||||
|
readonly side: AttributionSide;
|
||||||
|
readonly title: string;
|
||||||
|
/** 人类工具发卡 —— 打扰率的计量对象(四卡合计) */
|
||||||
|
readonly isHumanToolCall: boolean;
|
||||||
|
/** 判断次数中由 DMN / 机检确定性求值的 */
|
||||||
|
readonly isDeterministicJudgement: boolean;
|
||||||
|
readonly isJudgement: boolean;
|
||||||
|
/** loop 断点:当下可为与目标所需交集为空 */
|
||||||
|
readonly isBreakpoint: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskFact = {
|
||||||
|
readonly taskId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly startedAt: string;
|
||||||
|
readonly finishedAt?: string | undefined;
|
||||||
|
readonly terminal?: TerminalState | undefined;
|
||||||
|
/** 提交批次:每次放行提交 = 一个产出批次 */
|
||||||
|
readonly submissions: number;
|
||||||
|
/** 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 */
|
||||||
|
readonly corrections: number;
|
||||||
|
readonly tokens: number;
|
||||||
|
readonly activeMs: number;
|
||||||
|
readonly humanCalls: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 度量八指标(OS 8.3)—— 自主率与纠正率必须成对读 */
|
||||||
|
export type Metrics = {
|
||||||
|
readonly autonomy: number;
|
||||||
|
readonly correction: number;
|
||||||
|
readonly effectiveAutonomy: number;
|
||||||
|
readonly interruption: number;
|
||||||
|
readonly breakpoint: number;
|
||||||
|
readonly dmnCoverage: number;
|
||||||
|
/** 单位成本:总消耗 ÷ **放行通过**的任务数 —— 分母只认成功终态 */
|
||||||
|
readonly unitCost: { readonly tokens: number; readonly minutes: number; readonly humanCalls: number };
|
||||||
|
/** 终态分布:五个终态的占比 */
|
||||||
|
readonly terminalMix: Readonly<Record<TerminalState, number>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ratio = (num: number, den: number): number => (den === 0 ? 0 : num / den);
|
||||||
|
|
||||||
|
export function computeMetrics(logs: readonly LogFact[], tasks: readonly TaskFact[]): Metrics {
|
||||||
|
const machine = logs.filter((l) => l.side === 'machine').length;
|
||||||
|
const human = logs.filter((l) => l.side === 'human').length;
|
||||||
|
// 裁决与等待不进分母
|
||||||
|
const autonomy = ratio(machine, machine + human);
|
||||||
|
|
||||||
|
const submissions = tasks.reduce((s, t) => s + t.submissions, 0);
|
||||||
|
const corrections = tasks.reduce((s, t) => s + t.corrections, 0);
|
||||||
|
const correction = ratio(corrections, submissions);
|
||||||
|
|
||||||
|
const judgements = logs.filter((l) => l.isJudgement).length;
|
||||||
|
const deterministic = logs.filter((l) => l.isDeterministicJudgement).length;
|
||||||
|
|
||||||
|
const released = tasks.filter((t) => t.terminal === 'released');
|
||||||
|
const terminalCounts = tasks.reduce<Record<string, number>>((acc, t) => {
|
||||||
|
if (t.terminal !== undefined) acc[t.terminal] = (acc[t.terminal] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const terminalTotal = Object.values(terminalCounts).reduce((s, n) => s + n, 0);
|
||||||
|
|
||||||
|
const terminalMix = (Object.keys(TERMINAL_STATE_LABEL) as TerminalState[]).reduce(
|
||||||
|
(acc, state) => ({ ...acc, [state]: ratio(terminalCounts[state] ?? 0, terminalTotal) }),
|
||||||
|
{} as Record<TerminalState, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
autonomy,
|
||||||
|
correction,
|
||||||
|
effectiveAutonomy: autonomy * (1 - correction),
|
||||||
|
interruption: ratio(
|
||||||
|
tasks.reduce((s, t) => s + t.humanCalls, 0),
|
||||||
|
tasks.length,
|
||||||
|
),
|
||||||
|
breakpoint: ratio(logs.filter((l) => l.isBreakpoint).length, logs.length),
|
||||||
|
dmnCoverage: ratio(deterministic, judgements),
|
||||||
|
unitCost: {
|
||||||
|
// 环是否真在产出:分母只认放行通过,否则「跑得多」会被算成「产出多」
|
||||||
|
tokens: ratio(
|
||||||
|
tasks.reduce((s, t) => s + t.tokens, 0),
|
||||||
|
released.length,
|
||||||
|
),
|
||||||
|
minutes: ratio(
|
||||||
|
tasks.reduce((s, t) => s + t.activeMs, 0) / 60_000,
|
||||||
|
released.length,
|
||||||
|
),
|
||||||
|
humanCalls: ratio(
|
||||||
|
tasks.reduce((s, t) => s + t.humanCalls, 0),
|
||||||
|
released.length,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
terminalMix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportSection = {
|
||||||
|
readonly taskId: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly activityId: string;
|
||||||
|
readonly terminalLabel?: string | undefined;
|
||||||
|
/** 该窗口内这单任务的日志摘句 —— 汇报正文的骨架,零 LLM 即可成篇 */
|
||||||
|
readonly lines: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportDraft = {
|
||||||
|
readonly sections: readonly ReportSection[];
|
||||||
|
readonly metrics: Metrics;
|
||||||
|
readonly counts: { readonly touched: number; readonly released: number; readonly logLines: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 汇报草案:按任务分段,段内按时间列日志摘句。
|
||||||
|
* 「零手工汇报」的含义是**员工不写**,不是「系统编」——所以草案的每一句都取自任务日志,
|
||||||
|
* 叙述性小结(若配了系统用途模型)是锦上添花,不是事实来源。
|
||||||
|
*/
|
||||||
|
export function draftReport(
|
||||||
|
logs: readonly LogFact[],
|
||||||
|
tasks: readonly TaskFact[],
|
||||||
|
window: Window,
|
||||||
|
): ReportDraft {
|
||||||
|
const inWindow = logs.filter((l) => withinWindow(l.at, window));
|
||||||
|
const touchedIds = new Set(inWindow.map((l) => l.taskId));
|
||||||
|
const touched = tasks.filter((t) => touchedIds.has(t.taskId));
|
||||||
|
|
||||||
|
const sections = touched.map<ReportSection>((t) => ({
|
||||||
|
taskId: t.taskId,
|
||||||
|
title: t.title,
|
||||||
|
activityId: t.activityId,
|
||||||
|
terminalLabel: t.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[t.terminal],
|
||||||
|
lines: inWindow
|
||||||
|
.filter((l) => l.taskId === t.taskId)
|
||||||
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
|
||||||
|
.map((l) => l.title),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
metrics: computeMetrics(inWindow, touched),
|
||||||
|
counts: {
|
||||||
|
touched: touched.length,
|
||||||
|
released: touched.filter((t) => t.terminal === 'released').length,
|
||||||
|
logLines: inWindow.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 汇报周期与聚合 —— 零 IO、零框架、零 `Date.now()`(编码规范 §10.2)。
|
||||||
|
*
|
||||||
|
* 「按日、周、月、年自动总结此员工的工作汇报——由本人各任务的**任务日志**聚合生成,
|
||||||
|
* 确认即提交,**零手工汇报**。」(OS 2.1 / 8.1)
|
||||||
|
*
|
||||||
|
* 聚合是**投影**不是记账:同一份任务日志换个时间窗就是另一份汇报,
|
||||||
|
* 所以这里只做纯函数,不落任何中间态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const REPORT_PERIODS = ['day', 'week', 'month', 'year'] as const;
|
||||||
|
export type ReportPeriod = (typeof REPORT_PERIODS)[number];
|
||||||
|
|
||||||
|
export type Window = { readonly from: string; readonly to: string };
|
||||||
|
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
const iso = (d: Date): string => d.toISOString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周期窗口:左闭右开 `[from, to)`。
|
||||||
|
* 周以**周一**为首日(ISO 8601),不是周日 —— 中文语境的「本周」按周一起算。
|
||||||
|
* 一律按 UTC 切分:单组织单时区,不引入时区表;跨时区留待有需要时再谈。
|
||||||
|
*/
|
||||||
|
export function windowOf(period: ReportPeriod, anchor: Date): Window {
|
||||||
|
const y = anchor.getUTCFullYear();
|
||||||
|
const m = anchor.getUTCMonth();
|
||||||
|
const d = anchor.getUTCDate();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'day': {
|
||||||
|
const from = new Date(Date.UTC(y, m, d));
|
||||||
|
return { from: iso(from), to: iso(new Date(Date.UTC(y, m, d + 1))) };
|
||||||
|
}
|
||||||
|
case 'week': {
|
||||||
|
// getUTCDay(): 0=周日 → 距周一 6 天;1..6 → 距周一 day-1 天
|
||||||
|
const day = anchor.getUTCDay();
|
||||||
|
const backToMonday = day === 0 ? 6 : day - 1;
|
||||||
|
const from = new Date(Date.UTC(y, m, d - backToMonday));
|
||||||
|
return { from: iso(from), to: iso(new Date(Date.UTC(y, m, d - backToMonday + 7))) };
|
||||||
|
}
|
||||||
|
case 'month': {
|
||||||
|
return { from: iso(new Date(Date.UTC(y, m, 1))), to: iso(new Date(Date.UTC(y, m + 1, 1))) };
|
||||||
|
}
|
||||||
|
case 'year': {
|
||||||
|
return { from: iso(new Date(Date.UTC(y, 0, 1))), to: iso(new Date(Date.UTC(y + 1, 0, 1))) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人读的周期名 —— 汇报标题用 */
|
||||||
|
export function periodLabel(period: ReportPeriod, anchor: Date): string {
|
||||||
|
const y = anchor.getUTCFullYear();
|
||||||
|
switch (period) {
|
||||||
|
case 'day':
|
||||||
|
return `${y}-${pad(anchor.getUTCMonth() + 1)}-${pad(anchor.getUTCDate())} 日报`;
|
||||||
|
case 'week': {
|
||||||
|
const { from } = windowOf('week', anchor);
|
||||||
|
const start = new Date(from);
|
||||||
|
return `${start.getUTCFullYear()}-${pad(start.getUTCMonth() + 1)}-${pad(start.getUTCDate())} 起当周周报`;
|
||||||
|
}
|
||||||
|
case 'month':
|
||||||
|
return `${y}-${pad(anchor.getUTCMonth() + 1)} 月报`;
|
||||||
|
case 'year':
|
||||||
|
return `${y} 年报`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withinWindow = (at: string, w: Window): boolean => at >= w.from && at < w.to;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 汇报用例(编码规范 §10.2)。
|
||||||
|
*
|
||||||
|
* 「按日、周、月、年**自动**总结……确认即提交,**零手工汇报**。」(OS 2.1)
|
||||||
|
* 未提交的草案每次取回都重算 —— 汇报是投影,不是账本;提交那一刻才固定为事实快照。
|
||||||
|
*/
|
||||||
|
import type { Report, ReportService } from '../../contract.ts';
|
||||||
|
import { ReportAlreadySubmittedError, ReportNotFoundError } from '../../contract.ts';
|
||||||
|
import type { ReportDeps } from '../../port.ts';
|
||||||
|
import { computeMetrics, draftReport } from '../domain/aggregate.ts';
|
||||||
|
import { periodLabel, windowOf } from '../domain/period.ts';
|
||||||
|
|
||||||
|
export function createReportService(deps: ReportDeps): ReportService {
|
||||||
|
return {
|
||||||
|
async draft(staffId, period, anchor) {
|
||||||
|
const at = anchor ?? deps.clock.now();
|
||||||
|
const window = windowOf(period, at);
|
||||||
|
const [logs, tasks] = await Promise.all([
|
||||||
|
deps.facts.logsOf(staffId, window.from, window.to),
|
||||||
|
deps.facts.tasksOf(staffId),
|
||||||
|
]);
|
||||||
|
const draft = draftReport(logs, tasks, window);
|
||||||
|
|
||||||
|
const existing = await deps.store.findOpen(staffId, period, window.from);
|
||||||
|
const report: Report = {
|
||||||
|
id: existing?.id ?? deps.ids.next(),
|
||||||
|
staffId,
|
||||||
|
period,
|
||||||
|
title: periodLabel(period, at),
|
||||||
|
window,
|
||||||
|
draft,
|
||||||
|
...(existing?.note === undefined ? {} : { note: existing.note }),
|
||||||
|
generatedAt: deps.clock.now().toISOString(),
|
||||||
|
};
|
||||||
|
await deps.store.save(report);
|
||||||
|
return report;
|
||||||
|
},
|
||||||
|
|
||||||
|
list: (staffId, limit = 20) => deps.store.list(staffId, limit),
|
||||||
|
|
||||||
|
async get(reportId) {
|
||||||
|
const report = await deps.store.get(reportId);
|
||||||
|
if (report === undefined) throw new ReportNotFoundError(reportId);
|
||||||
|
return report;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submit(reportId, note) {
|
||||||
|
const report = await deps.store.get(reportId);
|
||||||
|
if (report === undefined) throw new ReportNotFoundError(reportId);
|
||||||
|
// 确认即提交:提交后不再改,否则「汇报」就成了可回改的说法
|
||||||
|
if (report.submittedAt !== undefined) throw new ReportAlreadySubmittedError(reportId);
|
||||||
|
const submitted: Report = {
|
||||||
|
...report,
|
||||||
|
...(note === undefined ? {} : { note }),
|
||||||
|
submittedAt: deps.clock.now().toISOString(),
|
||||||
|
};
|
||||||
|
await deps.store.save(submitted);
|
||||||
|
return submitted;
|
||||||
|
},
|
||||||
|
|
||||||
|
async metrics(staffId, period, anchor) {
|
||||||
|
const window = windowOf(period, anchor ?? deps.clock.now());
|
||||||
|
const [logs, tasks] = await Promise.all([
|
||||||
|
deps.facts.logsOf(staffId, window.from, window.to),
|
||||||
|
deps.facts.tasksOf(staffId),
|
||||||
|
]);
|
||||||
|
return computeMetrics(logs, tasks);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "name": "@modules/report", "private": true, "type": "module", "main": "index.ts" }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* module: report 的外部所需(编码规范 §10.1)。
|
||||||
|
* 汇报**不自己记账**:事实全部来自 memory(任务日志)与 task(终态 / 预算 / 纠正信号)。
|
||||||
|
*/
|
||||||
|
import type { StaffId } from '@contracts';
|
||||||
|
import type { Report } from './contract.ts';
|
||||||
|
import type { LogFact, TaskFact } from './internal/domain/aggregate.ts';
|
||||||
|
|
||||||
|
export type ReportStore = {
|
||||||
|
list(staffId: StaffId, limit: number): Promise<Report[]>;
|
||||||
|
get(reportId: string): Promise<Report | undefined>;
|
||||||
|
findOpen(staffId: StaffId, period: string, windowFrom: string): Promise<Report | undefined>;
|
||||||
|
save(report: Report): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 事实源 —— app 注入 `memory`(L2)与 `task`(L6);report 在 L8,只读不写 */
|
||||||
|
export type FactSource = {
|
||||||
|
/** 某人(或全员)在窗口内的归侧日志行 */
|
||||||
|
logsOf(staffId: StaffId | undefined, from: string, to: string): Promise<LogFact[]>;
|
||||||
|
tasksOf(staffId: StaffId | undefined): Promise<TaskFact[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReportDeps = {
|
||||||
|
readonly store: ReportStore;
|
||||||
|
readonly facts: FactSource;
|
||||||
|
readonly ids: { next(): string };
|
||||||
|
readonly clock: { now(): Date };
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE "sys"."minute_resolution" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"minute_id" text NOT NULL,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"owner_id" text,
|
||||||
|
"state" text DEFAULT 'open' NOT NULL,
|
||||||
|
"workflow_id" text,
|
||||||
|
"registered_by_id" text,
|
||||||
|
"registered_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sys"."minute" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"author_id" text NOT NULL,
|
||||||
|
"participants" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||||
|
"occurred_on" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "sys"."report" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"staff_id" text NOT NULL,
|
||||||
|
"period" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"window_from" text NOT NULL,
|
||||||
|
"window_to" text NOT NULL,
|
||||||
|
"draft" jsonb NOT NULL,
|
||||||
|
"note" text,
|
||||||
|
"submitted_at" timestamp with time zone,
|
||||||
|
"generated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "minute_resolution_minute_idx" ON "sys"."minute_resolution" USING btree ("minute_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "minute_occurred_idx" ON "sys"."minute" USING btree ("occurred_on");--> statement-breakpoint
|
||||||
|
CREATE INDEX "report_staff_period_idx" ON "sys"."report" USING btree ("staff_id","period","window_from");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
|||||||
"when": 1785089035016,
|
"when": 1785089035016,
|
||||||
"tag": "0007_swift_silver_samurai",
|
"tag": "0007_swift_silver_samurai",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785094481010,
|
||||||
|
"tag": "0008_strange_quasar",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import type { RouteContract } from './http.ts';
|
|||||||
import { sessionSchema } from '../org.ts';
|
import { sessionSchema } from '../org.ts';
|
||||||
import { chatMessageSchema, deliverableSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts';
|
import { chatMessageSchema, deliverableSchema, stageSchema, viewBlockSchema, workOrderSchema } from '../card.ts';
|
||||||
import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
|
import { attributionSideSchema, taskRuntimeStateSchema, terminalStateSchema, workflowStateSchema } from '../terminal.ts';
|
||||||
import { taskIdSchema } from '../id.ts';
|
import { staffIdSchema, taskIdSchema } from '../id.ts';
|
||||||
|
|
||||||
export const sessionRoute = {
|
export const sessionRoute = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -118,3 +118,136 @@ export const listMessagesRoute = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
} as const satisfies RouteContract;
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
/* ── 纪要:工作台五要素里唯一的主动动作 ─────────────────────────────── */
|
||||||
|
|
||||||
|
export const minuteResolutionSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
ownerId: staffIdSchema.optional(),
|
||||||
|
state: z.enum(['open', 'registered']),
|
||||||
|
workflowId: z.string().optional(),
|
||||||
|
registeredById: staffIdSchema.optional(),
|
||||||
|
registeredAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const minuteSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
body: z.string(),
|
||||||
|
authorId: staffIdSchema,
|
||||||
|
authorName: z.string(),
|
||||||
|
participants: z.array(z.string()),
|
||||||
|
occurredOn: z.string(),
|
||||||
|
resolutions: z.array(minuteResolutionSchema),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
export type MinuteView = z.infer<typeof minuteSchema>;
|
||||||
|
|
||||||
|
export const listMinutesRoute = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/minutes',
|
||||||
|
output: z.object({ minutes: z.array(minuteSchema) }),
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
export const createMinuteRoute = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/minutes',
|
||||||
|
body: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
body: z.string(),
|
||||||
|
occurredOn: z.string().min(1),
|
||||||
|
participants: z.array(staffIdSchema).default([]),
|
||||||
|
resolutions: z.array(z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() })).default([]),
|
||||||
|
}),
|
||||||
|
output: minuteSchema,
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
export const appendResolutionRoute = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/minutes/:minuteId/resolutions',
|
||||||
|
params: z.object({ minuteId: z.string() }),
|
||||||
|
body: z.object({ text: z.string().min(1), ownerId: staffIdSchema.optional() }),
|
||||||
|
output: minuteSchema,
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
/** 决议 → 登记起单:登记人即来源 */
|
||||||
|
export const registerResolutionRoute = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/minutes/resolutions/:resolutionId/register',
|
||||||
|
params: z.object({ resolutionId: z.string() }),
|
||||||
|
body: z.object({ processPackageId: z.string().min(1), data: z.record(z.string(), z.unknown()) }),
|
||||||
|
output: z.object({ workflowId: z.string(), resolutionId: z.string() }),
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
/* ── 汇报:系统代劳的自动产出 ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const reportPeriodSchema = z.enum(['day', 'week', 'month', 'year']);
|
||||||
|
export type ReportPeriodName = z.infer<typeof reportPeriodSchema>;
|
||||||
|
|
||||||
|
export const REPORT_PERIOD_LABEL: Readonly<Record<ReportPeriodName, string>> = {
|
||||||
|
day: '日报',
|
||||||
|
week: '周报',
|
||||||
|
month: '月报',
|
||||||
|
year: '年报',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportMetricsSchema = z.object({
|
||||||
|
autonomy: z.number(),
|
||||||
|
correction: z.number(),
|
||||||
|
effectiveAutonomy: z.number(),
|
||||||
|
interruption: z.number(),
|
||||||
|
breakpoint: z.number(),
|
||||||
|
dmnCoverage: z.number(),
|
||||||
|
unitCost: z.object({ tokens: z.number(), minutes: z.number(), humanCalls: z.number() }),
|
||||||
|
terminalMix: z.record(z.string(), z.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reportSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
period: reportPeriodSchema,
|
||||||
|
title: z.string(),
|
||||||
|
window: z.object({ from: z.string(), to: z.string() }),
|
||||||
|
sections: z.array(
|
||||||
|
z.object({
|
||||||
|
taskId: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
activityId: z.string(),
|
||||||
|
terminalLabel: z.string().optional(),
|
||||||
|
lines: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
metrics: reportMetricsSchema,
|
||||||
|
counts: z.object({ touched: z.number().int(), released: z.number().int(), logLines: z.number().int() }),
|
||||||
|
note: z.string().optional(),
|
||||||
|
submittedAt: z.string().optional(),
|
||||||
|
generatedAt: z.string(),
|
||||||
|
});
|
||||||
|
export type ReportView = z.infer<typeof reportSchema>;
|
||||||
|
|
||||||
|
/** 未提交的草案每次取回都重算 —— 汇报是投影,不是账本 */
|
||||||
|
export const draftReportRoute = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/reports/:period',
|
||||||
|
params: z.object({ period: reportPeriodSchema }),
|
||||||
|
output: reportSchema,
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
export const listReportsRoute = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/reports',
|
||||||
|
output: z.object({ reports: z.array(reportSchema) }),
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
/** 确认即提交,提交后不再改 */
|
||||||
|
export const submitReportRoute = {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/reports/:reportId/submit',
|
||||||
|
params: z.object({ reportId: z.string() }),
|
||||||
|
body: z.object({ note: z.string().optional() }),
|
||||||
|
output: reportSchema,
|
||||||
|
} as const satisfies RouteContract;
|
||||||
|
|
||||||
|
export type ListMinutesOutput = z.infer<typeof listMinutesRoute.output>;
|
||||||
|
export type ListReportsOutput = z.infer<typeof listReportsRoute.output>;
|
||||||
|
|||||||
Reference in New Issue
Block a user