feat(工时): 增加通用工时汇总引擎
This commit is contained in:
31
apps/web/lib/work-effort-engine.test.ts
Normal file
31
apps/web/lib/work-effort-engine.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { aggregateWorkEffort, roundHalfHour } from './work-effort-engine';
|
||||||
|
|
||||||
|
test('roundHalfHour rounds to nearest half hour', () => {
|
||||||
|
assert.equal(roundHalfHour(0.24), 0);
|
||||||
|
assert.equal(roundHalfHour(0.25), 0.5);
|
||||||
|
assert.equal(roundHalfHour(1.24), 1);
|
||||||
|
assert.equal(roundHalfHour(1.25), 1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregateWorkEffort calculates weighted progress by estimate', () => {
|
||||||
|
const result = aggregateWorkEffort([
|
||||||
|
{ estimateHours: 1, actualHours: 0, progress: 0 },
|
||||||
|
{ estimateHours: 3, actualHours: 1.25, progress: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.estimateHours, 4);
|
||||||
|
assert.equal(result.actualHours, 1.5);
|
||||||
|
assert.equal(result.progress, 75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregateWorkEffort falls back to item average when estimates are zero', () => {
|
||||||
|
const result = aggregateWorkEffort([
|
||||||
|
{ estimateHours: 0, actualHours: 0, progress: 50 },
|
||||||
|
{ estimateHours: 0, actualHours: 0, progress: 100 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.progress, 75);
|
||||||
|
});
|
||||||
42
apps/web/lib/work-effort-engine.ts
Normal file
42
apps/web/lib/work-effort-engine.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export interface WorkEffortItem {
|
||||||
|
estimateHours: number;
|
||||||
|
actualHours: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkEffortSummary {
|
||||||
|
estimateHours: number;
|
||||||
|
actualHours: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundHalfHour(hours: number): number {
|
||||||
|
if (!Number.isFinite(hours) || hours <= 0) return 0;
|
||||||
|
return Math.round(hours * 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateWorkEffort(items: WorkEffortItem[]): WorkEffortSummary {
|
||||||
|
if (items.length === 0) return { estimateHours: 0, actualHours: 0, progress: 0 };
|
||||||
|
|
||||||
|
const estimateHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.estimateHours || 0), 0));
|
||||||
|
const actualHours = roundHalfHour(items.reduce((sum, item) => sum + Math.max(0, item.actualHours || 0), 0));
|
||||||
|
|
||||||
|
if (estimateHours <= 0) {
|
||||||
|
return {
|
||||||
|
estimateHours,
|
||||||
|
actualHours,
|
||||||
|
progress: Math.round(items.reduce((sum, item) => sum + item.progress, 0) / items.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightedProgress = items.reduce((sum, item) => {
|
||||||
|
const estimate = Math.max(0, item.estimateHours || 0);
|
||||||
|
return sum + estimate * item.progress;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
estimateHours,
|
||||||
|
actualHours,
|
||||||
|
progress: Math.round(weightedProgress / estimateHours),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user