Files
ftb-project-management/apps/web/lib/dev-task.ts
2026-07-01 13:33:04 +08:00

224 lines
6.9 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 { Priority } from './derive';
import { calcActualElapsedHours, 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;
versionId?: string;
requirementId: string;
requirementName?: string;
title: string;
description?: string;
categoryId: string;
assigneeId: string;
reviewerId?: string;
priority: Priority;
expectedStartAt: string;
expectedEndAt: string;
estimateHours?: number;
aiEstimateHours?: 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<DevTaskStatus, string> = {
todo: '待开发',
in_progress: '开发中',
testing: '自测',
submitted: '已提测',
};
export const DEV_TASK_STATUS_COLOR: Record<DevTaskStatus, string> = {
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<DevTaskStatus, number> = {
todo: 0,
in_progress: 50,
testing: 80,
submitted: 100,
};
export const ALLOWED_TRANSITIONS: Record<DevTaskStatus, DevTaskStatus[]> = {
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 roundEffortHours(task.estimateHours);
}
if (typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0) {
return roundEffortHours(task.aiEstimateHours);
}
if (!task.expectedStartAt || !task.expectedEndAt) return 0;
return calcWorkHours(task.expectedStartAt, task.expectedEndAt);
}
function roundEffortHours(hours: number): number {
return Number(hours.toFixed(2));
}
export function needsDevTaskClaim(task: Pick<DevTask, 'assigneeId'>): boolean {
return !task.assigneeId?.trim();
}
export function hasDevTaskPlan(task: Pick<DevTask, 'expectedStartAt' | 'expectedEndAt'>): boolean {
if (!task.expectedStartAt || !task.expectedEndAt) return false;
const start = new Date(task.expectedStartAt).getTime();
const end = new Date(task.expectedEndAt).getTime();
return Number.isFinite(start) && Number.isFinite(end) && end > start;
}
export function canStartDevTask(task: Pick<DevTask, 'assigneeId' | 'expectedStartAt' | 'expectedEndAt'>): boolean {
return !needsDevTaskClaim(task) && hasDevTaskPlan(task);
}
export function getActualHours(task: DevTask, now: Date = new Date()): number {
if (!task.actualStartAt) return 0;
const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());
return calcActualElapsedHours(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<string, { actualHours: number; taskCount: number }>();
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 ?? (t.status === 'submitted' ? t.updatedAt : 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;
}