Files
ftb-project-management/apps/web/lib/workspace-daily-report.ts
Script Generator 8947aeaac7 feat(工作台): 细化计划进展工时证据
关键改动:

- 支持需求覆盖和调研方向开始工作记录

- 日报按计划记录开始时间和进度下次开始时间计算证据

- 更新版本编辑校验、项目展示和 workflow 说明

Co-Authored-By: Codex GPT-5 <codex@openai.com>
2026-07-01 11:02:38 +08:00

564 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { TaskWorklog } from './task-worklog';
import type { WorkActivity, WorkActivityCategory } from './work-activity';
import type { WorkItem } from './workspace-engine';
import { calcActualElapsedHours } from './work-hours';
export interface WorkspaceDailyReportItem {
id: string;
taskId: string;
taskTitle: string;
workContent: string;
hours: number;
createdAt: string;
productName?: string;
projectName?: string;
versionName?: string;
}
export interface WorkspaceDailyReportActivityItem {
id: string;
sourceId: string;
sourceType: WorkActivity['sourceType'];
action: WorkActivity['action'];
category: WorkActivityCategory;
title: string;
summary: string;
occurredAt: string;
context?: string;
metadata?: WorkActivity['metadata'];
}
export interface WorkspaceDailyReportNeedsProgressItem {
id: string;
title: string;
status: string;
type: WorkItem['type'];
context?: string;
}
export type WorkspaceDailyReportGroups = Record<WorkActivityCategory, WorkspaceDailyReportActivityItem[]>;
export interface WorkspaceDailyReport {
date: string;
totalHours: number;
totalCount: number;
items: WorkspaceDailyReportItem[];
groups: WorkspaceDailyReportGroups;
needsProgressItems: WorkspaceDailyReportNeedsProgressItem[];
}
interface GetWorkspaceDailyReportInput {
activities?: WorkActivity[];
worklogs: TaskWorklog[];
workItems: WorkItem[];
userId: string;
date: string;
now?: Date;
}
export function getWorkspaceDailyReport({
activities = [],
worklogs,
workItems,
userId,
date,
now = new Date(),
}: GetWorkspaceDailyReportInput): WorkspaceDailyReport {
const workItemMap = new Map(workItems.map((item) => [item.id, item]));
const items = worklogs
.filter((log) => log.userId === userId && log.date === date)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((log) => {
const item = workItemMap.get(log.taskId);
return {
id: log.id,
taskId: log.taskId,
taskTitle: item?.title ?? '未知任务',
workContent: log.workContent,
hours: log.hours,
createdAt: log.createdAt,
productName: item?.productName,
projectName: item?.projectName,
versionName: item?.versionName,
};
});
const userActivities = activities
.filter((activity) => activity.actorId === userId)
.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
const dailyActivities = userActivities
.filter((activity) => activity.date === date)
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const activityKeys = new Set(dailyActivities.map(getActivityKey));
const evidenceSourceIds = new Set([
...dailyActivities.map((activity) => activity.sourceId),
...items.map((item) => item.taskId),
]);
const reportActivities = [
...dailyActivities,
...buildTimestampFallbackActivities(workItems, userId, date, activityKeys, evidenceSourceIds),
].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
const groups = makeEmptyGroups();
for (const activity of reportActivities) {
const item = workItemMap.get(activity.sourceId);
groups[activity.category].push({
id: activity.id,
sourceId: activity.sourceId,
sourceType: activity.sourceType,
action: activity.action,
category: activity.category,
title: activity.title,
summary: activity.summary,
occurredAt: activity.occurredAt,
context: getContext(item),
metadata: activity.metadata,
});
}
const touchedWorkItemIds = buildTouchedWorkItemIds(reportActivities, items, workItems);
const worklogTaskIds = new Set(items.map((item) => item.taskId));
const activityHours = calcActivityHoursForDate(
reportActivities,
userActivities,
workItemMap,
worklogTaskIds,
date,
now,
);
const needsProgressItems = workItems
.filter((item) => shouldRequireProgress(item, date, touchedWorkItemIds))
.map((item) => ({
id: item.id,
title: item.title,
status: item.status,
type: item.type,
context: getContext(item),
}));
return {
date,
totalHours: roundHalfHour(items.reduce((sum, item) => sum + item.hours, 0) + activityHours),
totalCount: items.length + reportActivities.length,
items,
groups,
needsProgressItems,
};
}
function makeEmptyGroups(): WorkspaceDailyReportGroups {
return {
delivery: [],
progress: [],
creation: [],
risk: [],
note: [],
};
}
function getContext(item?: WorkItem): string | undefined {
if (!item) return undefined;
return [item.productName, item.projectName, item.versionName].filter(Boolean).join(' / ');
}
function getActivityKey(activity: Pick<WorkActivity, 'sourceId' | 'action'>): string {
return `${activity.sourceId}:${activity.action}`;
}
function getRaw(item: WorkItem): Record<string, unknown> {
return ((item.raw ?? {}) as unknown) as Record<string, unknown>;
}
function buildTouchedWorkItemIds(
activities: WorkActivity[],
items: WorkspaceDailyReportItem[],
workItems: WorkItem[],
): Set<string> {
const touched = new Set([
...activities.map((activity) => activity.sourceId),
...items.map((item) => item.taskId),
]);
const touchedRequirementIds = new Set(
activities
.map(getPlanProgressRequirementId)
.filter((requirementId): requirementId is string => Boolean(requirementId)),
);
if (touchedRequirementIds.size === 0) return touched;
for (const item of workItems) {
const requirementId = getWorkItemRequirementId(item);
if (requirementId && touchedRequirementIds.has(requirementId)) {
touched.add(item.id);
}
}
return touched;
}
function getPlanProgressRequirementId(activity: WorkActivity): string | undefined {
if (activity.sourceType !== 'version_plan' || activity.action !== 'version_plan_requirement_progress') return undefined;
return asString(activity.metadata?.requirementId);
}
function getWorkItemRequirementId(item: WorkItem): string | undefined {
const raw = getRaw(item);
return asString(raw.requirementId) ?? asString(item.extra?.requirementId);
}
function shouldRequireProgress(item: WorkItem, date: string, touchedWorkItemIds: Set<string>): boolean {
if (item.completed) return false;
if (touchedWorkItemIds.has(item.id)) return false;
if (!isInProgressStatus(item.status)) return false;
const startedAt = getStartedAt(item);
if (!startedAt) return false;
return startedAt.slice(0, 10) < date;
}
function isInProgressStatus(status: string): boolean {
return ['in_progress', 'testing', 'running', 'fixing', 'verifying'].includes(status);
}
function getStartedAt(item: WorkItem): string | undefined {
const extra = item.extra ?? {};
if (typeof extra.actualStartAt === 'string') return extra.actualStartAt;
if (typeof extra.startedAt === 'string') return extra.startedAt;
if (typeof extra.startTime === 'string') return extra.startTime;
return undefined;
}
function buildTimestampFallbackActivities(
workItems: WorkItem[],
userId: string,
date: string,
existingKeys: Set<string>,
evidenceSourceIds: Set<string>,
): WorkActivity[] {
const out: WorkActivity[] = [];
for (const item of workItems) {
if (evidenceSourceIds.has(item.id)) continue;
for (const draft of getTimestampFallbackDrafts(item)) {
if (!isWithinLocalDate(draft.occurredAt, date)) continue;
const activity: WorkActivity = {
id: `fallback-${draft.action}-${item.id}`,
actorId: userId,
date,
sourceType: draft.sourceType,
sourceId: item.id,
action: draft.action,
category: draft.category,
title: item.title,
summary: draft.summary,
occurredAt: draft.occurredAt,
};
const key = getActivityKey(activity);
if (existingKeys.has(key)) continue;
existingKeys.add(key);
out.push(activity);
}
}
return out;
}
function getTimestampFallbackDrafts(item: WorkItem): Array<Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'>> {
const raw = getRaw(item);
const extra = item.extra ?? {};
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
return [
makeFallbackDraft('version_plan', 'version_plan_created', 'creation', asString(raw.createdAt), `新建计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始计划:${item.title}`),
makeFallbackDraft('version_plan', 'version_plan_completed', 'delivery', asString(raw.completedAt), `完成计划:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'devTask') {
return [
makeFallbackDraft('dev_task', 'dev_task_created', 'creation', asString(raw.createdAt), `新建开发任务:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_started', 'progress', asString(raw.actualStartAt) ?? asString(extra.actualStartAt), `开始开发:${item.title}`),
makeFallbackDraft('dev_task', 'dev_task_submitted', 'delivery', asString(raw.actualEndAt) ?? asString(extra.actualEndAt), `已提测开发任务:${item.title}`),
].filter(isFallbackDraft);
}
if (item.type === 'testCase') {
const completedAt = asString(raw.completedAt) ?? asString(extra.completedAt);
const terminal = getTestCaseTerminalFallback(item.status, item.title, completedAt);
return [
makeFallbackDraft('test_case', 'test_case_created', 'creation', asString(raw.createdAt), `新建测试用例:${item.title}`),
makeFallbackDraft('test_case', 'test_case_started', 'progress', asString(raw.startedAt) ?? asString(extra.startedAt), `开始测试:${item.title}`),
terminal,
].filter(isFallbackDraft);
}
if (item.type === 'bug') {
return [
makeFallbackDraft('bug', 'bug_created', 'creation', asString(raw.createdAt), `新建 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixing', 'progress', getBugFixingStartedAt(raw), `开始修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_fixed', 'delivery', asString(raw.resolvedAt), `已修复 Bug${item.title}`),
makeFallbackDraft('bug', 'bug_closed', 'delivery', asString(raw.closedAt), `已关闭 Bug${item.title}`),
].filter(isFallbackDraft);
}
return [];
}
function makeFallbackDraft(
sourceType: WorkActivity['sourceType'],
action: WorkActivity['action'],
category: WorkActivityCategory,
occurredAt: string | undefined,
summary: string,
): Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (!occurredAt) return undefined;
return { sourceType, action, category, summary, occurredAt };
}
function isFallbackDraft(
draft: Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined,
): draft is Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> {
return Boolean(draft);
}
function getTestCaseTerminalFallback(
status: string,
title: string,
completedAt: string | undefined,
): Pick<WorkActivity, 'sourceType' | 'action' | 'category' | 'summary' | 'occurredAt'> | undefined {
if (status === 'passed') return makeFallbackDraft('test_case', 'test_case_passed', 'delivery', completedAt, `测试通过:${title}`);
if (status === 'failed') return makeFallbackDraft('test_case', 'test_case_failed', 'risk', completedAt, `测试不通过:${title}`);
if (status === 'blocked') return makeFallbackDraft('test_case', 'test_case_blocked', 'risk', completedAt, `测试阻塞:${title}`);
return undefined;
}
function calcActivityHoursForDate(
activities: WorkActivity[],
allUserActivities: WorkActivity[],
workItemMap: Map<string, WorkItem>,
excludedSourceIds: Set<string>,
date: string,
now: Date,
): number {
const recordScopedSourceIds = new Set<string>();
const intervals = activities
.filter((activity) => !excludedSourceIds.has(activity.sourceId))
.map((activity) => {
if (!isRecordScopedProgressActivity(activity)) return undefined;
recordScopedSourceIds.add(activity.sourceId);
const workStartedAt = getRecordScopedWorkStartedAt(activity, allUserActivities, workItemMap, date);
if (!workStartedAt) return undefined;
return getActivityIntervalForDate(workStartedAt, activity.occurredAt, date);
})
.filter(isTimeInterval);
const sourceIds = Array.from(new Set(activities.map((activity) => activity.sourceId)));
const fallbackIntervals = sourceIds
.filter((sourceId) => !excludedSourceIds.has(sourceId))
.filter((sourceId) => !recordScopedSourceIds.has(sourceId))
.map((sourceId) => getWorkItemIntervalForDate(workItemMap.get(sourceId), date, now))
.filter(isTimeInterval);
return calcMergedIntervalHours([...intervals, ...fallbackIntervals]);
}
function isPlanRecordProgressActivity(activity: WorkActivity): boolean {
return activity.sourceType === 'version_plan'
&& (activity.action === 'version_plan_requirement_progress'
|| activity.action === 'version_plan_research_direction_progress');
}
function isProgressNoteRecordActivity(activity: WorkActivity): boolean {
return activity.action === 'progress_note_added' && Boolean(asString(activity.metadata?.nextStartAt));
}
function isRecordScopedProgressActivity(activity: WorkActivity): boolean {
return isPlanRecordProgressActivity(activity) || isProgressNoteRecordActivity(activity);
}
function getRecordScopedWorkStartedAt(
activity: WorkActivity,
allUserActivities: WorkActivity[],
workItemMap: Map<string, WorkItem>,
date: string,
): string | undefined {
if (isPlanRecordProgressActivity(activity)) {
return asString(activity.metadata?.workStartedAt)
?? getPreviousRecordNextStartAt(activity, allUserActivities)
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
}
if (isProgressNoteRecordActivity(activity)) {
return getPreviousRecordNextStartAt(activity, allUserActivities)
?? getSameDayWorkItemStartAt(workItemMap.get(activity.sourceId), date);
}
return undefined;
}
function getPreviousRecordNextStartAt(activity: WorkActivity, allUserActivities: WorkActivity[]): string | undefined {
const currentTime = new Date(activity.occurredAt).getTime();
if (!Number.isFinite(currentTime)) return undefined;
return [...allUserActivities]
.filter((item) => item.sourceId === activity.sourceId && item.occurredAt < activity.occurredAt)
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((item) => asString(item.metadata?.nextStartAt))
.find((nextStartAt) => {
if (!nextStartAt) return false;
const startTime = new Date(nextStartAt).getTime();
return Number.isFinite(startTime) && startTime < currentTime;
});
}
function getSameDayWorkItemStartAt(item: WorkItem | undefined, date: string): string | undefined {
if (!item) return undefined;
const startAt = getStartedAt(item);
if (!startAt || !isWithinLocalDate(startAt, date)) return undefined;
return startAt;
}
function getActivityIntervalForDate(
startAt: string,
occurredAt: string,
date: string,
): { start: number; end: number } | undefined {
const day = getLocalDateBounds(date);
if (!day) return undefined;
const start = new Date(startAt).getTime();
const end = new Date(occurredAt).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return undefined;
return { start: overlapStart, end: overlapEnd };
}
function getWorkItemIntervalForDate(item: WorkItem | undefined, date: string, now: Date): { start: number; end: number } | undefined {
const interval = getActualInterval(item, now);
if (!interval) return undefined;
const day = getLocalDateBounds(date);
if (!day) return undefined;
const start = new Date(interval.start).getTime();
const end = new Date(interval.end).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return undefined;
const overlapStart = Math.max(start, day.start.getTime());
const overlapEnd = Math.min(end, day.end.getTime());
if (overlapEnd <= overlapStart) return undefined;
return { start: overlapStart, end: overlapEnd };
}
function isTimeInterval(interval: { start: number; end: number } | undefined): interval is { start: number; end: number } {
return Boolean(interval);
}
function calcMergedIntervalHours(intervals: Array<{ start: number; end: number }>): number {
if (intervals.length === 0) return 0;
const sorted = [...intervals].sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number }> = [];
let current = { ...sorted[0] };
for (const interval of sorted.slice(1)) {
if (interval.start <= current.end) {
current.end = Math.max(current.end, interval.end);
} else {
merged.push(current);
current = { ...interval };
}
}
merged.push(current);
return merged.reduce((sum, interval) => sum + calcActualElapsedHours(
new Date(interval.start).toISOString(),
new Date(interval.end).toISOString(),
), 0);
}
function getActualInterval(item: WorkItem | undefined, now: Date): { start: string; end: string } | undefined {
if (!item) return undefined;
const raw = getRaw(item);
const extra = item.extra ?? {};
const nowIso = now.toISOString();
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.completedAt) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'devTask') {
const start = asString(raw.actualStartAt) ?? asString(extra.actualStartAt);
const end = asString(raw.actualEndAt) ?? asString(extra.actualEndAt) ?? (item.status === 'submitted' ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'testCase') {
const start = asString(raw.startedAt) ?? asString(extra.startedAt);
const isTerminal = ['passed', 'failed', 'blocked'].includes(item.status);
const end = asString(raw.completedAt) ?? asString(extra.completedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
if (item.type === 'bug') {
const start = getBugFixingStartedAt(raw) ?? asString(raw.createdAt) ?? asString(extra.createdAt);
const isTerminal = ['closed', 'rejected'].includes(item.status);
const end = asString(raw.closedAt) ?? asString(raw.resolvedAt) ?? (isTerminal ? asString(raw.updatedAt) : undefined) ?? nowIso;
return start ? { start, end } : undefined;
}
return undefined;
}
function getBugFixingStartedAt(raw: Record<string, unknown>): string | undefined {
const logs = raw.logs;
if (!Array.isArray(logs)) return undefined;
const fixingLog = logs.find((log) => {
if (!log || typeof log !== 'object') return false;
const row = log as Record<string, unknown>;
return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string';
}) as Record<string, unknown> | undefined;
return asString(fixingLog?.createdAt);
}
function getLocalDateBounds(date: string): { start: Date; end: Date } | undefined {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
if (!match) return undefined;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return undefined;
const start = new Date(year, month - 1, day, 0, 0, 0, 0);
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0);
if (!Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) return undefined;
return { start, end };
}
function isWithinLocalDate(value: string, date: string): boolean {
const bounds = getLocalDateBounds(date);
if (!bounds) return false;
const time = new Date(value).getTime();
if (!Number.isFinite(time)) return false;
return time >= bounds.start.getTime() && time < bounds.end.getTime();
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function roundHalfHour(hours: number): number {
if (!Number.isFinite(hours) || hours <= 0) return 0;
return Math.round(hours * 2) / 2;
}