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 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): string { return `${activity.sourceId}:${activity.action}`; } function getRaw(item: WorkItem): Record { return ((item.raw ?? {}) as unknown) as Record; } function buildTouchedWorkItemIds( activities: WorkActivity[], items: WorkspaceDailyReportItem[], workItems: WorkItem[], ): Set { 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): 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, 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 calcActivityHoursForDate( activities: WorkActivity[], allUserActivities: WorkActivity[], workItemMap: Map, excludedSourceIds: Set, date: string, now: Date, ): number { const recordScopedSourceIds = new Set(); 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, 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 | 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; }