fix(version): 修正概览耗时与排名统计

This commit is contained in:
Script Generator
2026-06-26 13:06:22 +08:00
parent 4cda624dc7
commit dde7a9a623
9 changed files with 857 additions and 367 deletions

View File

@@ -1,5 +1,5 @@
import type { Priority } from './derive';
import { calcWorkHours, type TimeInterval } from './work-hours';
import { calcActualElapsedHours, type TimeInterval } from './work-hours';
export type BugStatus = 'open' | 'fixing' | 'fixed' | 'verifying' | 'closed' | 'rejected';
export type BugSeverity = 'critical' | 'major' | 'minor' | 'trivial';
@@ -32,6 +32,8 @@ export interface Bug {
resolvedAt?: string;
closedAt?: string;
resolution?: string;
estimateHours?: number;
aiEstimateHours?: number;
createdAt: string;
updatedAt: string;
}
@@ -93,7 +95,7 @@ export function getBugActualHours(bug: Bug, now: Date = new Date()): number {
const start = bug.createdAt;
if (!start) return 0;
const end = bug.closedAt ?? bug.resolvedAt ?? (bug.status === 'closed' || bug.status === 'rejected' ? bug.updatedAt : now.toISOString());
return calcWorkHours(start, end);
return calcActualElapsedHours(start, end);
}
export function aggregateBugActualHours(bugs: Bug[], now: Date = new Date()): number {

View File

@@ -1,5 +1,5 @@
import type { Priority } from './derive';
import { calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
import { calcActualElapsedHours, calcWorkHours, formatWorkHours, type TimeInterval } from './work-hours';
export type DevTaskStatus = 'todo' | 'in_progress' | 'testing' | 'submitted';
@@ -114,8 +114,8 @@ function roundEffortHours(hours: number): number {
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);
const end = task.actualEndAt ?? (task.status === 'submitted' ? task.updatedAt : now.toISOString());
return calcActualElapsedHours(task.actualStartAt, end);
}
export interface AggregatedHours {
@@ -167,7 +167,7 @@ export function devTaskIntervals(tasks: DevTask[], now: Date = new Date()): Time
const nowIso = now.toISOString();
for (const t of tasks) {
if (!t.actualStartAt) continue;
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? nowIso });
out.push({ start: t.actualStartAt, end: t.actualEndAt ?? (t.status === 'submitted' ? t.updatedAt : nowIso) });
}
return out;
}

View File

@@ -1,7 +1,7 @@
import type { Priority } from './derive';
import type { Reference } from './dev-task';
import { DEFAULT_TEST_CATEGORY_ID } from './task-category';
import { calcWorkHours, type TimeInterval } from './work-hours';
import { calcActualElapsedHours, type TimeInterval } from './work-hours';
import { aggregateWorkEffort } from './work-effort-engine';
export type TestCaseStatus = 'pending' | 'running' | 'passed' | 'failed' | 'blocked';
@@ -138,8 +138,9 @@ export function getTestCaseEstimateHours(tc: TestCase): number {
export function getTestCaseActualHours(tc: TestCase, now: Date = new Date()): number {
if (!tc.startedAt) return 0;
const end = tc.completedAt ?? now.toISOString();
return calcWorkHours(tc.startedAt, end);
const isTerminal = tc.status === 'passed' || tc.status === 'failed' || tc.status === 'blocked';
const end = tc.completedAt ?? (isTerminal ? tc.updatedAt : now.toISOString());
return calcActualElapsedHours(tc.startedAt, end);
}
export function aggregateTestCaseHours(cases: TestCase[], now: Date = new Date()): { estimate: number; actual: number } {
@@ -166,7 +167,8 @@ export function testCaseIntervals(cases: TestCase[], now: Date = new Date()): Ti
const nowIso = now.toISOString();
for (const c of cases) {
if (!c.startedAt) continue;
out.push({ start: c.startedAt, end: c.completedAt ?? nowIso });
const isTerminal = c.status === 'passed' || c.status === 'failed' || c.status === 'blocked';
out.push({ start: c.startedAt, end: c.completedAt ?? (isTerminal ? c.updatedAt : nowIso) });
}
return out;
}

View File

@@ -0,0 +1,262 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
calcBugSeverityRanking,
calcPersonalEffortRanking,
calcStageEffortMetrics,
calcVersionOverviewEffortTotals,
} from './version-overview';
import type { VersionPlan } from './version-plan';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { Bug } from './bug';
import type { OvertimeRecord } from './overtime';
function plan(patch: Partial<VersionPlan>): VersionPlan {
return {
id: patch.id || 'plan-1',
versionId: patch.versionId || 'v-1',
type: patch.type || 'research',
title: patch.title || '计划',
owner: patch.owner || 'Alice',
startTime: patch.startTime || '2026-06-22T09:00:00',
endTime: patch.endTime || '2026-06-22T18:00:00',
status: patch.status || 'completed',
actualStartAt: patch.actualStartAt,
completedAt: patch.completedAt,
createdAt: patch.createdAt || '2026-06-22T08:00:00',
addedBy: patch.addedBy || 'Alice',
};
}
function devTask(patch: Partial<DevTask>): DevTask {
return {
id: patch.id || 'dev-1',
taskNo: patch.taskNo || 'DEV-001',
requirementId: patch.requirementId || 'req-1',
title: patch.title || '开发任务',
categoryId: patch.categoryId || 'cat-1',
assigneeId: patch.assigneeId || 'Alice',
priority: patch.priority || 'P2',
expectedStartAt: patch.expectedStartAt || '2026-06-22T09:00:00',
expectedEndAt: patch.expectedEndAt || '2026-06-22T18:00:00',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
actualStartAt: patch.actualStartAt,
actualEndAt: patch.actualEndAt,
status: patch.status || 'submitted',
isBlocked: patch.isBlocked ?? false,
createdBy: patch.createdBy || 'Alice',
createdAt: patch.createdAt || '2026-06-22T08:00:00',
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
};
}
function testCase(patch: Partial<TestCase>): TestCase {
return {
id: patch.id || 'tc-1',
caseNo: patch.caseNo || 'TC-001',
versionId: patch.versionId || 'v-1',
title: patch.title || '测试用例',
categoryId: patch.categoryId || 'cat-1',
priority: patch.priority || 'P2',
assigneeId: patch.assigneeId || 'Bob',
status: patch.status || 'passed',
estimateHours: patch.estimateHours,
aiEstimateHours: patch.aiEstimateHours,
startedAt: patch.startedAt,
completedAt: patch.completedAt,
createdBy: patch.createdBy || 'Bob',
createdAt: patch.createdAt || '2026-06-22T08:00:00',
updatedAt: patch.updatedAt || '2026-06-22T18:00:00',
};
}
function bug(patch: Partial<Bug>): Bug {
return {
id: patch.id || 'bug-1',
bugNo: patch.bugNo || 'BUG-001',
versionId: patch.versionId || 'v-1',
testCaseId: patch.testCaseId || 'tc-1',
title: patch.title || 'Bug',
description: patch.description || '描述',
severity: patch.severity || 'major',
priority: patch.priority || 'P1',
reportedBy: patch.reportedBy || 'QA',
assigneeId: patch.assigneeId || 'Bob',
status: patch.status || 'closed',
createdAt: patch.createdAt || '2026-06-22T10:00:00',
updatedAt: patch.updatedAt || '2026-06-22T12:00:00',
closedAt: patch.closedAt,
resolvedAt: patch.resolvedAt,
};
}
test('calcStageEffortMetrics returns actual hours and AI estimates per stage', () => {
const metrics = calcStageEffortMetrics({
plans: [
plan({ type: 'research', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' }),
plan({ type: 'product', actualStartAt: '2026-06-23T09:00:00', completedAt: '2026-06-23T12:00:00' }),
],
devTasks: [
devTask({
actualStartAt: '2026-06-24T09:00:00',
actualEndAt: '2026-06-24T14:00:00',
estimateHours: 4.5,
aiEstimateHours: 3.25,
}),
],
testCases: [
testCase({
startedAt: '2026-06-25T09:00:00',
completedAt: '2026-06-25T11:00:00',
estimateHours: 2.25,
aiEstimateHours: 1.5,
}),
],
bugs: [
bug({ createdAt: '2026-06-26T10:00:00', closedAt: '2026-06-26T10:10:00' }),
],
});
assert.equal(metrics.requirement.actualHours, 9);
assert.equal(metrics.product_design.actualHours, 3);
assert.equal(metrics.dev.actualHours, 5);
assert.equal(metrics.dev.estimateHours, 4.5);
assert.equal(metrics.dev.aiEstimateHours, 3.25);
assert.equal(metrics.testing.actualHours, 2);
assert.equal(metrics.testing.estimateHours, 2.25);
assert.equal(metrics.testing.aiEstimateHours, 1.5);
assert.equal(metrics.bug.actualHours, 0.5);
});
test('calcStageEffortMetrics uses updatedAt for terminal test cases missing completedAt', () => {
const metrics = calcStageEffortMetrics({
plans: [],
devTasks: [],
testCases: [
testCase({
status: 'passed',
startedAt: '2026-06-25T09:00:00',
completedAt: undefined,
updatedAt: '2026-06-25T09:20:00',
}),
],
bugs: [],
now: new Date('2026-06-26T18:00:00'),
});
assert.equal(metrics.testing.actualHours, 0.5);
});
test('calcVersionOverviewEffortTotals sums actual hours and overtime records separately', () => {
const overtimeRecords: OvertimeRecord[] = [
{
id: 'ot-1',
projectId: 'project-1',
versionId: 'v-1',
person: 'Alice',
startTime: '2026-06-22T19:00:00',
endTime: '2026-06-22T20:15:00',
duration: 1.25,
reasonId: 'reason-3',
createdAt: '2026-06-22T20:20:00',
},
{
id: 'ot-2',
projectId: 'project-1',
versionId: 'v-1',
person: 'Bob',
startTime: '2026-06-22T20:00:00',
endTime: '2026-06-22T22:03:00',
duration: 2.05,
reasonId: 'reason-4',
createdAt: '2026-06-22T22:10:00',
},
];
const totals = calcVersionOverviewEffortTotals({
plans: [plan({ actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
devTasks: [devTask({ actualStartAt: '2026-06-22T10:00:00', actualEndAt: '2026-06-22T12:00:00' })],
testCases: [],
bugs: [],
overtimeRecords,
});
assert.equal(totals.actualHours, 11);
assert.equal(totals.overtimeHours, 3.3);
});
test('calcPersonalEffortRanking includes bug work and sorts by total hours', () => {
const overtimeRecords: OvertimeRecord[] = [
{
id: 'ot-1',
projectId: 'project-1',
versionId: 'v-1',
person: 'Alice',
startTime: '2026-06-23T19:00:00',
endTime: '2026-06-23T20:30:00',
duration: 1.5,
reasonId: 'reason-3',
createdAt: '2026-06-23T20:35:00',
},
{
id: 'ot-2',
projectId: 'project-1',
versionId: 'v-1',
person: 'Bob',
startTime: '2026-06-25T19:00:00',
endTime: '2026-06-25T21:00:00',
duration: 2,
reasonId: 'reason-4',
createdAt: '2026-06-25T21:10:00',
},
];
const ranking = calcPersonalEffortRanking({
plans: [plan({ owner: 'Alice', actualStartAt: '2026-06-22T09:00:00', completedAt: '2026-06-22T18:00:00' })],
devTasks: [devTask({ assigneeId: 'Alice', actualStartAt: '2026-06-23T09:00:00', actualEndAt: '2026-06-23T11:00:00' })],
testCases: [testCase({ assigneeId: 'Bob', startedAt: '2026-06-24T09:00:00', completedAt: '2026-06-24T11:00:00' })],
bugs: [bug({ assigneeId: 'Bob', createdAt: '2026-06-25T10:00:00', closedAt: '2026-06-25T12:00:00' })],
overtimeRecords,
});
assert.equal(ranking[0].name, 'Alice');
assert.equal(ranking[0].actualHours, 11);
assert.equal(ranking[0].overtimeHours, 1.5);
assert.equal(ranking[0].total, 12.5);
assert.equal(ranking[1].name, 'Bob');
assert.equal(ranking[1].actualHours, 4);
assert.equal(ranking[1].overtimeHours, 2);
assert.equal(ranking[1].total, 6);
});
test('calcBugSeverityRanking groups bugs by assignee and severity', () => {
const ranking = calcBugSeverityRanking([
bug({ id: 'bug-1', assigneeId: 'Alice', severity: 'critical' }),
bug({ id: 'bug-2', assigneeId: 'Alice', severity: 'major' }),
bug({ id: 'bug-3', assigneeId: 'Alice', severity: 'minor' }),
bug({ id: 'bug-4', assigneeId: 'Bob', severity: 'critical' }),
bug({ id: 'bug-5', assigneeId: 'Bob', severity: 'trivial' }),
bug({ id: 'bug-6', assigneeId: 'Alice', severity: 'trivial' }),
]);
assert.deepEqual(ranking, [
{
assigneeId: 'Alice',
critical: 1,
major: 1,
minor: 1,
trivial: 1,
total: 4,
},
{
assigneeId: 'Bob',
critical: 1,
major: 0,
minor: 0,
trivial: 1,
total: 2,
},
]);
});

View File

@@ -0,0 +1,230 @@
import 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 { getActualHours as getDevTaskActualHours } from './dev-task';
import { getTestCaseActualHours } from './test-case';
import { getBugActualHours } from './bug';
import { calcActualElapsedHours } from './work-hours';
export interface StageEffortMetric {
actualHours: number;
estimateHours?: number;
aiEstimateHours?: number;
showEstimates?: boolean;
}
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;
}
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 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),
);
}

View File

@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calcActualElapsedHours, formatActualDuration } from './work-hours';
test('calcActualElapsedHours uses real elapsed time with a half-hour minimum', () => {
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T09:10:00'), 0.5);
assert.equal(calcActualElapsedHours('2026-06-22T09:00:00', '2026-06-22T10:15:00'), 1.5);
assert.equal(calcActualElapsedHours('2026-06-22T10:00:00', '2026-06-22T09:00:00'), 0);
});
test('formatActualDuration converts hours to natural days', () => {
assert.equal(formatActualDuration(60.5), '60.5h2.5天)');
assert.equal(formatActualDuration(0.5), '0.5h0.02天)');
assert.equal(formatActualDuration(0), '0h0天');
});

View File

@@ -94,6 +94,36 @@ export function formatWorkHoursShort(hours: number): string {
return `${hours}h`;
}
/**
* 计算真实经过时长,而不是工作时段内时长。
*
* 用于“实际耗时”口径:按开始/结束时间戳直接相减,精度 0.5h
* 只要有正向耗时,最低按 0.5h 计,避免 30 分钟内工作被显示为 0。
*/
export function calcActualElapsedHours(startISO?: string | null, endISO?: string | null): number {
if (!startISO || !endISO) return 0;
const start = new Date(startISO).getTime();
const end = new Date(endISO).getTime();
if (isNaN(start) || isNaN(end) || end <= start) return 0;
const hours = (end - start) / MS_PER_HOUR;
return Math.max(0.5, Math.round(hours * 2) / 2);
}
function formatNaturalDays(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0天';
const days = hours / 24;
if (days < 0.1) return `${Number(days.toFixed(2))}`;
return `${Number.isInteger(days) ? String(days) : days.toFixed(1).replace(/\.0$/, '')}`;
}
/**
* 实际耗时显示,天数按自然日 24h 换算。
*/
export function formatActualDuration(hours: number): string {
if (!isFinite(hours) || hours <= 0) return '0h0天';
return `${hours}h${formatNaturalDays(hours)}`;
}
/**
* 仅天数版:"Y天"
*/