关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import { STAGES, type Stage } from './stage';
|
|
import type { VersionPlan } from './version-plan';
|
|
import type { DevTask } from './dev-task';
|
|
import type { TestCase } from './test-case';
|
|
import type { Bug, BugSeverity } from './bug';
|
|
import type { OvertimeRecord } from './overtime';
|
|
import type { VersionStatus } from './version-status';
|
|
import { getActualHours as getDevTaskActualHours } from './dev-task';
|
|
import { getTestCaseActualHours } from './test-case';
|
|
import { getBugActualHours } from './bug';
|
|
import { calcActualElapsedHours } from './work-hours';
|
|
import { formatDateTime } from './format';
|
|
|
|
export interface StageEffortMetric {
|
|
actualHours: number;
|
|
estimateHours?: number;
|
|
aiEstimateHours?: number;
|
|
showEstimates?: boolean;
|
|
}
|
|
|
|
export interface StageProgressState {
|
|
percent: number;
|
|
status: 'idle' | 'active' | 'done';
|
|
}
|
|
|
|
export type StageProgressWithEffort = StageProgressState & StageEffortMetric;
|
|
|
|
export interface PersonalEffortItem {
|
|
name: string;
|
|
actualHours: number;
|
|
overtimeHours: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface BugSeverityRankingItem {
|
|
assigneeId: string;
|
|
critical: number;
|
|
major: number;
|
|
minor: number;
|
|
trivial: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface VersionOverviewEffortTotals {
|
|
actualHours: number;
|
|
overtimeHours: number;
|
|
}
|
|
|
|
export interface VersionTimelineSummary {
|
|
actualStartIso: string | null;
|
|
expectedReleaseIso: string | null;
|
|
actualReleaseIso: string | null;
|
|
actualEndIso: string | null;
|
|
isTerminalVersion: boolean;
|
|
actualHours: number;
|
|
overdueDays: number;
|
|
}
|
|
|
|
function roundHalf(hours: number): number {
|
|
return Math.round(hours * 2) / 2;
|
|
}
|
|
|
|
function roundTenth(hours: number): number {
|
|
return Math.round(hours * 10) / 10;
|
|
}
|
|
|
|
function roundHundredth(hours: number): number {
|
|
return Math.round(hours * 100) / 100;
|
|
}
|
|
|
|
function sumAiEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
|
|
const total = items.reduce((sum, item) => {
|
|
const value = getValue(item);
|
|
return sum + (typeof value === 'number' && value > 0 ? value : 0);
|
|
}, 0);
|
|
return total > 0 ? roundHundredth(total) : undefined;
|
|
}
|
|
|
|
function sumEstimate<T>(items: T[], getValue: (item: T) => number | undefined): number | undefined {
|
|
const total = items.reduce((sum, item) => {
|
|
const value = getValue(item);
|
|
return sum + (typeof value === 'number' && value > 0 ? value : 0);
|
|
}, 0);
|
|
return total > 0 ? roundHundredth(total) : undefined;
|
|
}
|
|
|
|
function getPlanActualHours(plan: VersionPlan, now: Date): number {
|
|
if (!plan.actualStartAt) return 0;
|
|
return calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now.toISOString());
|
|
}
|
|
|
|
function sumActualHours<T>(items: T[], getValue: (item: T) => number): number {
|
|
return roundHalf(items.reduce((sum, item) => sum + getValue(item), 0));
|
|
}
|
|
|
|
export function calcStageEffortMetrics(input: {
|
|
plans: VersionPlan[];
|
|
devTasks: DevTask[];
|
|
testCases: TestCase[];
|
|
bugs: Bug[];
|
|
now?: Date;
|
|
}): Record<Stage, StageEffortMetric> {
|
|
const now = input.now ?? new Date();
|
|
const researchPlans = input.plans.filter((p) => p.type === 'research');
|
|
const productPlans = input.plans.filter((p) => p.type === 'product');
|
|
const uiPlans = input.plans.filter((p) => p.type === 'ui');
|
|
|
|
return {
|
|
requirement: {
|
|
actualHours: sumActualHours(researchPlans, (plan) => getPlanActualHours(plan, now)),
|
|
},
|
|
product_design: {
|
|
actualHours: sumActualHours(productPlans, (plan) => getPlanActualHours(plan, now)),
|
|
},
|
|
ui_design: {
|
|
actualHours: sumActualHours(uiPlans, (plan) => getPlanActualHours(plan, now)),
|
|
},
|
|
dev: {
|
|
actualHours: sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now)),
|
|
estimateHours: sumEstimate(input.devTasks, (task) => task.estimateHours),
|
|
aiEstimateHours: sumAiEstimate(input.devTasks, (task) => task.aiEstimateHours),
|
|
showEstimates: true,
|
|
},
|
|
testing: {
|
|
actualHours: sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now)),
|
|
estimateHours: sumEstimate(input.testCases, (testCase) => testCase.estimateHours),
|
|
aiEstimateHours: sumAiEstimate(input.testCases, (testCase) => testCase.aiEstimateHours),
|
|
showEstimates: true,
|
|
},
|
|
bug: {
|
|
actualHours: sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now)),
|
|
estimateHours: sumEstimate(input.bugs, (bug) => bug.estimateHours),
|
|
aiEstimateHours: sumAiEstimate(input.bugs, (bug) => bug.aiEstimateHours),
|
|
showEstimates: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function mergeStageProgressWithEffort(
|
|
progress: Partial<Record<Stage, StageProgressState>>,
|
|
effortMetrics: Record<Stage, StageEffortMetric>,
|
|
): Record<Stage, StageProgressWithEffort> {
|
|
return STAGES.reduce((acc, stage) => {
|
|
const state = progress[stage.key];
|
|
acc[stage.key] = {
|
|
percent: state?.percent ?? 0,
|
|
status: state?.status ?? 'idle',
|
|
...effortMetrics[stage.key],
|
|
};
|
|
return acc;
|
|
}, {} as Record<Stage, StageProgressWithEffort>);
|
|
}
|
|
|
|
export function getVersionCardDefaultExpanded(status: VersionStatus): boolean {
|
|
return status === 'developing';
|
|
}
|
|
|
|
export function formatVersionOverviewDateTime(value?: string | null): string {
|
|
if (!value) return '-';
|
|
return value.includes('T') ? formatDateTime(value) : value;
|
|
}
|
|
|
|
export function buildVersionTimelineSummary(input: {
|
|
status: VersionStatus;
|
|
startDate?: string | null;
|
|
expectedReleaseDate?: string | null;
|
|
releaseDate?: string | null;
|
|
plans: VersionPlan[];
|
|
devTasks: DevTask[];
|
|
testCases: TestCase[];
|
|
bugs: Bug[];
|
|
now?: Date;
|
|
}): VersionTimelineSummary {
|
|
const now = input.now ?? new Date();
|
|
const startDates: string[] = [];
|
|
|
|
input.plans.forEach((plan) => {
|
|
if (plan.actualStartAt) {
|
|
startDates.push(plan.actualStartAt);
|
|
} else if (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= now) {
|
|
startDates.push(plan.startTime);
|
|
}
|
|
});
|
|
input.devTasks.forEach((task) => {
|
|
if (task.actualStartAt) startDates.push(task.actualStartAt);
|
|
});
|
|
input.testCases.forEach((testCase) => {
|
|
if (testCase.startedAt) startDates.push(testCase.startedAt);
|
|
});
|
|
|
|
const actualStartIso = startDates.length > 0 ? startDates.sort()[0] : (input.startDate ?? null);
|
|
|
|
const endDates: string[] = [];
|
|
input.plans.forEach((plan) => {
|
|
if (plan.completedAt) endDates.push(plan.completedAt);
|
|
});
|
|
input.devTasks.forEach((task) => {
|
|
if (task.actualEndAt) endDates.push(task.actualEndAt);
|
|
});
|
|
input.testCases.forEach((testCase) => {
|
|
if (testCase.completedAt) endDates.push(testCase.completedAt);
|
|
});
|
|
input.bugs.forEach((bug) => {
|
|
if (bug.closedAt) endDates.push(bug.closedAt);
|
|
else if (bug.resolvedAt) endDates.push(bug.resolvedAt);
|
|
else if ((bug.status === 'closed' || bug.status === 'rejected') && bug.updatedAt) endDates.push(bug.updatedAt);
|
|
});
|
|
|
|
const actualEndIso = endDates.length > 0 ? endDates.sort().reverse()[0] : null;
|
|
const isTerminalVersion = input.status === 'released' || input.status === 'closed';
|
|
const actualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString());
|
|
|
|
let overdueDays = 0;
|
|
if (input.expectedReleaseDate && input.releaseDate) {
|
|
const endDate = new Date(input.releaseDate);
|
|
const deadlineDate = new Date(input.expectedReleaseDate);
|
|
endDate.setHours(0, 0, 0, 0);
|
|
deadlineDate.setHours(0, 0, 0, 0);
|
|
overdueDays = Math.floor((endDate.getTime() - deadlineDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
return {
|
|
actualStartIso,
|
|
expectedReleaseIso: input.expectedReleaseDate ?? null,
|
|
actualReleaseIso: input.releaseDate ?? null,
|
|
actualEndIso,
|
|
isTerminalVersion,
|
|
actualHours,
|
|
overdueDays,
|
|
};
|
|
}
|
|
|
|
export function calcVersionOverviewEffortTotals(input: {
|
|
plans: VersionPlan[];
|
|
devTasks: DevTask[];
|
|
testCases: TestCase[];
|
|
bugs: Bug[];
|
|
overtimeRecords: OvertimeRecord[];
|
|
now?: Date;
|
|
}): VersionOverviewEffortTotals {
|
|
const now = input.now ?? new Date();
|
|
const planHours = sumActualHours(input.plans, (plan) => getPlanActualHours(plan, now));
|
|
const devHours = sumActualHours(input.devTasks, (task) => getDevTaskActualHours(task, now));
|
|
const testHours = sumActualHours(input.testCases, (testCase) => getTestCaseActualHours(testCase, now));
|
|
const bugHours = sumActualHours(input.bugs, (bug) => getBugActualHours(bug, now));
|
|
const actualHours = roundHalf(planHours + devHours + testHours + bugHours);
|
|
const overtimeHours = roundTenth(input.overtimeRecords.reduce((sum, record) => sum + record.duration, 0));
|
|
|
|
return { actualHours, overtimeHours };
|
|
}
|
|
|
|
export function calcPersonalEffortRanking(input: {
|
|
plans: VersionPlan[];
|
|
devTasks: DevTask[];
|
|
testCases: TestCase[];
|
|
bugs: Bug[];
|
|
overtimeRecords?: OvertimeRecord[];
|
|
now?: Date;
|
|
}): PersonalEffortItem[] {
|
|
const now = input.now ?? new Date();
|
|
const personalHours = new Map<string, { actualHours: number; overtimeHours: number }>();
|
|
|
|
const addActualHours = (name: string | undefined, hours: number) => {
|
|
if (!name || hours <= 0) return;
|
|
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
|
|
prev.actualHours += hours;
|
|
personalHours.set(name, prev);
|
|
};
|
|
|
|
const addOvertimeHours = (name: string | undefined, hours: number) => {
|
|
if (!name || hours <= 0) return;
|
|
const prev = personalHours.get(name) || { actualHours: 0, overtimeHours: 0 };
|
|
prev.overtimeHours += hours;
|
|
personalHours.set(name, prev);
|
|
};
|
|
|
|
input.plans.forEach((plan) => {
|
|
addActualHours(plan.owner, getPlanActualHours(plan, now));
|
|
});
|
|
|
|
input.devTasks.forEach((task) => {
|
|
addActualHours(task.assigneeId, getDevTaskActualHours(task, now));
|
|
});
|
|
|
|
input.testCases.forEach((testCase) => {
|
|
addActualHours(testCase.assigneeId, getTestCaseActualHours(testCase, now));
|
|
});
|
|
|
|
input.bugs.forEach((bug) => {
|
|
addActualHours(bug.assigneeId, getBugActualHours(bug, now));
|
|
});
|
|
|
|
(input.overtimeRecords ?? []).forEach((record) => {
|
|
addOvertimeHours(record.person, record.duration);
|
|
});
|
|
|
|
return Array.from(personalHours.entries())
|
|
.map(([name, hours]) => {
|
|
const actualHours = roundHalf(hours.actualHours);
|
|
const overtimeHours = roundTenth(hours.overtimeHours);
|
|
return {
|
|
name,
|
|
actualHours,
|
|
overtimeHours,
|
|
total: roundTenth(actualHours + overtimeHours),
|
|
};
|
|
})
|
|
.sort((a, b) => b.total - a.total);
|
|
}
|
|
|
|
function createBugSeverityRankingItem(assigneeId: string): BugSeverityRankingItem {
|
|
return {
|
|
assigneeId,
|
|
critical: 0,
|
|
major: 0,
|
|
minor: 0,
|
|
trivial: 0,
|
|
total: 0,
|
|
};
|
|
}
|
|
|
|
export function calcBugSeverityRanking(bugs: Bug[]): BugSeverityRankingItem[] {
|
|
const rows = new Map<string, BugSeverityRankingItem>();
|
|
const severityOrder: BugSeverity[] = ['critical', 'major', 'minor', 'trivial'];
|
|
|
|
bugs.forEach((bug) => {
|
|
if (!bug.assigneeId || !severityOrder.includes(bug.severity)) return;
|
|
const row = rows.get(bug.assigneeId) ?? createBugSeverityRankingItem(bug.assigneeId);
|
|
row[bug.severity] += 1;
|
|
row.total += 1;
|
|
rows.set(bug.assigneeId, row);
|
|
});
|
|
|
|
return Array.from(rows.values()).sort(
|
|
(a, b) =>
|
|
b.total - a.total ||
|
|
b.critical - a.critical ||
|
|
b.major - a.major ||
|
|
b.minor - a.minor ||
|
|
b.trivial - a.trivial ||
|
|
a.assigneeId.localeCompare(b.assigneeId),
|
|
);
|
|
}
|