import type { Priority } from './derive'; import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours'; export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted'; export type ReferenceType = 'requirement' | 'prototype_note' | 'external'; export interface Reference { type: ReferenceType; id: string; label: string; url?: string; } export interface DevTask { id: string; taskNo: string; requirementId: string; title: string; description?: string; categoryId: string; assigneeId: string; reviewerId?: string; priority: Priority; expectedStartAt: string; expectedEndAt: string; estimateHours?: number; actualStartAt?: string; actualEndAt?: string; status: DevTaskStatus; isBlocked: boolean; blockReason?: string; blockedById?: string; predecessorIds?: string[]; riskLevel?: 'low' | 'medium' | 'high'; delayReason?: string; overdueVersionReason?: string; references?: Reference[]; aiDraft?: boolean; aiDraftAt?: string; createdBy: string; createdAt: string; updatedAt: string; } export const DEV_TASK_STATUS_LABEL: Record = { todo: '待开发', in_progress: '开发中', testing: '自测', submitted: '已提测', }; export const DEV_TASK_STATUS_COLOR: Record = { todo: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', testing: 'bg-purple-50 text-purple-600', submitted: 'bg-emerald-50 text-emerald-600', }; export const STATUS_PROGRESS: Record = { todo: 0, in_progress: 50, testing: 80, submitted: 100, }; export const ALLOWED_TRANSITIONS: Record = { todo: ['in_progress'], in_progress: ['testing'], testing: ['submitted', 'in_progress'], submitted: [], }; export function canTransition(from: DevTaskStatus, to: DevTaskStatus): boolean { return ALLOWED_TRANSITIONS[from].includes(to); } export function calcTaskProgress(task: DevTask): number { return STATUS_PROGRESS[task.status]; } export function calcGroupProgress(tasks: DevTask[]): number { if (tasks.length === 0) return 0; const totalEstimate = tasks.reduce((sum, t) => sum + getEstimateHours(t), 0); if (totalEstimate === 0) { const sum = tasks.reduce((s, t) => s + STATUS_PROGRESS[t.status], 0); return Math.round(sum / tasks.length); } const weighted = tasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0); return Math.round(weighted / totalEstimate); } export function formatHours(hours: number): string { return formatWorkHours(hours); } export function getEstimateHours(task: DevTask): number { if (typeof task.estimateHours === 'number' && task.estimateHours > 0) { return Math.round(task.estimateHours * 2) / 2; } if (!task.expectedStartAt || !task.expectedEndAt) return 0; return calcWorkHours(task.expectedStartAt, task.expectedEndAt); } export function getActualHours(task: DevTask, now: Date = new Date()): number { if (!task.actualStartAt) return 0; const end = task.actualEndAt ?? now.toISOString(); return calcWorkHours(task.actualStartAt, end); } export interface AggregatedHours { estimate: number; actual: number; } export function aggregateDevTaskHours(tasks: DevTask[], now: Date = new Date()): AggregatedHours { let estimate = 0; let actual = 0; for (const t of tasks) { estimate += getEstimateHours(t); actual += getActualHours(t, now); } return { estimate: Math.round(estimate * 2) / 2, actual: Math.round(actual * 2) / 2, }; } export function aggregateActualHoursByAssignee( tasks: DevTask[], now: Date = new Date(), ): Array<{ assigneeId: string; actualHours: number; taskCount: number }> { const map = new Map(); for (const t of tasks) { const h = getActualHours(t, now); if (h <= 0 && t.status !== 'submitted') continue; const cur = map.get(t.assigneeId) || { actualHours: 0, taskCount: 0 }; cur.actualHours += h; cur.taskCount += 1; map.set(t.assigneeId, cur); } return Array.from(map.entries()) .map(([assigneeId, v]) => ({ assigneeId, actualHours: Math.round(v.actualHours * 2) / 2, taskCount: v.taskCount, })) .sort((a, b) => b.actualHours - a.actualHours); } /** * 抽取每条已开干任务的 [actualStartAt, actualEndAt ?? now] 时间区间 * 用于双口径耗时统计(calcTwoMetrics) */ export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): TimeInterval[] { const out: TimeInterval[] = []; const nowIso = now.toISOString(); for (const t of tasks) { if (!t.actualStartAt) continue; out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso }); } return out; } export function generateTaskNo(existingTasks: DevTask[]): string { const maxNum = existingTasks.reduce((max, t) => { const num = parseInt(t.taskNo.replace('DEV-', ''), 10); return isNaN(num) ? max : Math.max(max, num); }, 0); return `DEV-${String(maxNum + 1).padStart(3, '0')}`; } export function isLegacyTask(t: any): boolean { if (!t || typeof t !== 'object') return false; return ( 'startDate' in t || 'dueDate' in t || 'completedAt' in t || 'actualHours' in t || 'overdueReason' in t ); } /** * 兼容函数:按纯小时差计算耗时(保留给 PlanTab/TestCase 体系使用) * DevTask 体系应改用 getActualHours/getEstimateHours 走 calcWorkHours */ export function calcActualHoursByDates(startDate?: string, completedAt?: string): number { if (!startDate) return 0; const end = completedAt || new Date().toISOString(); const startMs = new Date(startDate).getTime(); const endMs = new Date(end).getTime(); if (isNaN(startMs) || isNaN(endMs) || endMs < startMs) return 0; const diffHours = (endMs - startMs) / (1000 * 60 * 60); return Math.round(diffHours * 2) / 2; }