Archived
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,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 };
|
||||
};
|
||||
Reference in New Issue
Block a user