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; 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 dailyActivities = activities .filter((activity) => activity.actorId === userId && 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 touchedSourceIds = new Set([ ...reportActivities.map((activity) => activity.sourceId), ...items.map((item) => item.taskId), ]); const worklogTaskIds = new Set(items.map((item) => item.taskId)); const activityHours = Array.from(new Set(reportActivities.map((activity) => activity.sourceId))) .filter((sourceId) => !worklogTaskIds.has(sourceId)) .reduce((sum, sourceId) => { const item = workItemMap.get(sourceId); return sum + calcWorkItemHoursForDate(item, date, now); }, 0); const needsProgressItems = workItems .filter((item) => shouldRequireProgress(item, date, touchedSourceIds)) .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): string { return `${activity.sourceId}:${activity.action}`; } function getRaw(item: WorkItem): Record { return ((item.raw ?? {}) as unknown) as Record; } function shouldRequireProgress(item: WorkItem, date: string, touchedSourceIds: Set): boolean { if (item.completed) return false; if (touchedSourceIds.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, evidenceSourceIds: Set, ): 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> { 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 | undefined { if (!occurredAt) return undefined; return { sourceType, action, category, summary, occurredAt }; } function isFallbackDraft( draft: Pick | undefined, ): draft is Pick { return Boolean(draft); } function getTestCaseTerminalFallback( status: string, title: string, completedAt: string | undefined, ): Pick | 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 calcWorkItemHoursForDate(item: WorkItem | undefined, date: string, now: Date): number { const interval = getActualInterval(item, now); if (!interval) return 0; const day = getLocalDateBounds(date); if (!day) return 0; const start = new Date(interval.start).getTime(); const end = new Date(interval.end).getTime(); if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0; const overlapStart = Math.max(start, day.start.getTime()); const overlapEnd = Math.min(end, day.end.getTime()); if (overlapEnd <= overlapStart) return 0; return calcActualElapsedHours( new Date(overlapStart).toISOString(), new Date(overlapEnd).toISOString(), ); } 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 | 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; return row.action === 'status_change' && row.toValue === 'fixing' && typeof row.createdAt === 'string'; }) as Record | 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; }