199 lines
5.8 KiB
TypeScript
199 lines
5.8 KiB
TypeScript
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<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 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<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 ?? 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;
|
||
}
|