fix(工时): 按中国工作日历统计正常耗时
This commit is contained in:
@@ -120,9 +120,9 @@ test('calcStageEffortMetrics returns actual hours and AI estimates per stage', (
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(metrics.requirement.actualHours, 9);
|
||||
assert.equal(metrics.requirement.actualHours, 8);
|
||||
assert.equal(metrics.product_design.actualHours, 3);
|
||||
assert.equal(metrics.dev.actualHours, 5);
|
||||
assert.equal(metrics.dev.actualHours, 4);
|
||||
assert.equal(metrics.dev.estimateHours, 4.5);
|
||||
assert.equal(metrics.dev.aiEstimateHours, 3.25);
|
||||
assert.equal(metrics.testing.actualHours, 2);
|
||||
@@ -184,7 +184,7 @@ test('calcVersionOverviewEffortTotals sums actual hours and overtime records sep
|
||||
overtimeRecords,
|
||||
});
|
||||
|
||||
assert.equal(totals.actualHours, 11);
|
||||
assert.equal(totals.actualHours, 10);
|
||||
assert.equal(totals.overtimeHours, 3.3);
|
||||
});
|
||||
|
||||
@@ -222,9 +222,9 @@ test('calcPersonalEffortRanking includes bug work and sorts by total hours', ()
|
||||
});
|
||||
|
||||
assert.equal(ranking[0].name, 'Alice');
|
||||
assert.equal(ranking[0].actualHours, 11);
|
||||
assert.equal(ranking[0].actualHours, 10);
|
||||
assert.equal(ranking[0].overtimeHours, 1.5);
|
||||
assert.equal(ranking[0].total, 12.5);
|
||||
assert.equal(ranking[0].total, 11.5);
|
||||
assert.equal(ranking[1].name, 'Bob');
|
||||
assert.equal(ranking[1].actualHours, 4);
|
||||
assert.equal(ranking[1].overtimeHours, 2);
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { calcActualElapsedHours, formatActualDuration } from './work-hours';
|
||||
import { calcActualElapsedHours, calcWorkHours, formatActualDuration } from './work-hours';
|
||||
import { calcDuration } from './overtime';
|
||||
|
||||
test('calcActualElapsedHours uses real elapsed time with a half-hour minimum', () => {
|
||||
test('calcWorkHours skips official holidays and counts makeup workdays', () => {
|
||||
assert.equal(calcWorkHours('2026-10-01T09:00:00', '2026-10-01T18:00:00'), 0);
|
||||
assert.equal(calcWorkHours('2026-10-10T09:00:00', '2026-10-10T18:00:00'), 8);
|
||||
});
|
||||
|
||||
test('calcActualElapsedHours uses work calendar 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-10-01T09:00:00', '2026-10-01T18:00:00'), 0);
|
||||
assert.equal(calcActualElapsedHours('2026-10-10T09:00:00', '2026-10-10T10:00:00'), 1);
|
||||
assert.equal(calcActualElapsedHours('2026-06-22T10:00:00', '2026-06-22T09:00:00'), 0);
|
||||
});
|
||||
|
||||
test('overtime duration still counts holiday time directly', () => {
|
||||
assert.equal(calcDuration('2026-10-01T09:00:00', '2026-10-01T18:00:00'), 9);
|
||||
});
|
||||
|
||||
test('formatActualDuration converts hours to natural days', () => {
|
||||
assert.equal(formatActualDuration(60.5), '60.5h(2.5天)');
|
||||
assert.equal(formatActualDuration(0.5), '0.5h(0.02天)');
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isChinaWorkday } from './china-workday-calendar';
|
||||
|
||||
export const WORK_HOURS = {
|
||||
morningStart: 9,
|
||||
morningEnd: 12,
|
||||
@@ -27,7 +29,7 @@ function dayBound(d: Date, hourFloat: number): Date {
|
||||
}
|
||||
|
||||
function dayOverlapHours(start: Date, end: Date, day: Date): number {
|
||||
if (WORK_HOURS.skipWeekends && isWeekend(day)) return 0;
|
||||
if (WORK_HOURS.skipWeekends && !isChinaWorkday(day)) return 0;
|
||||
const morningStart = dayBound(day, WORK_HOURS.morningStart);
|
||||
const morningEnd = dayBound(day, WORK_HOURS.morningEnd);
|
||||
const afternoonStart = dayBound(day, WORK_HOURS.afternoonStart);
|
||||
@@ -55,7 +57,7 @@ function dayOverlapHours(start: Date, end: Date, day: Date): number {
|
||||
* - 全在周末 周六全天 = 0h
|
||||
* - 负区间 / 非法 ISO = 0
|
||||
*/
|
||||
export function calcWorkHours(startISO: string, endISO: string): number {
|
||||
function calcWorkHoursRaw(startISO: string, endISO: string): number {
|
||||
if (!startISO || !endISO) return 0;
|
||||
const start = new Date(startISO);
|
||||
const end = new Date(endISO);
|
||||
@@ -73,6 +75,11 @@ export function calcWorkHours(startISO: string, endISO: string): number {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
export function calcWorkHours(startISO: string, endISO: string): number {
|
||||
const total = calcWorkHoursRaw(startISO, endISO);
|
||||
return Math.round(total * 2) / 2;
|
||||
}
|
||||
|
||||
@@ -100,12 +107,11 @@ export function formatWorkHoursShort(hours: number): string {
|
||||
* 用于“实际耗时”口径:按开始/结束时间戳直接相减,精度 0.5h;
|
||||
* 只要有正向耗时,最低按 0.5h 计,避免 30 分钟内工作被显示为 0。
|
||||
*/
|
||||
// 实际耗时只统计中国工作日历内的工作时段;加班通过 overtime 记录单独计入。
|
||||
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;
|
||||
const hours = calcWorkHoursRaw(startISO, endISO);
|
||||
if (hours <= 0) return 0;
|
||||
return Math.max(0.5, Math.round(hours * 2) / 2);
|
||||
}
|
||||
|
||||
@@ -146,7 +152,7 @@ export function addWorkHours(startISO: string, hours: number): string {
|
||||
while (remaining > 0) {
|
||||
const day = new Date(cursor);
|
||||
day.setHours(0, 0, 0, 0);
|
||||
if (WORK_HOURS.skipWeekends && isWeekend(day)) {
|
||||
if (WORK_HOURS.skipWeekends && !isChinaWorkday(day)) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
cursor.setHours(WORK_HOURS.morningStart, 0, 0, 0);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user