From aa6970876a80ff017ad81c8592f8d44f0e131625 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 29 Jun 2026 15:04:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=B7=A5=E6=97=B6):=20=E6=8C=89=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=E5=B7=A5=E4=BD=9C=E6=97=A5=E5=8E=86=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E8=80=97=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/lib/version-overview.test.ts | 10 +++++----- apps/web/lib/work-hours.test.ts | 16 ++++++++++++++-- apps/web/lib/work-hours.ts | 20 +++++++++++++------- docs/decisions.md | 12 ++++++++++++ docs/workflow.md | 7 +++++++ 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/web/lib/version-overview.test.ts b/apps/web/lib/version-overview.test.ts index 75ce65d..edf633d 100644 --- a/apps/web/lib/version-overview.test.ts +++ b/apps/web/lib/version-overview.test.ts @@ -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); diff --git a/apps/web/lib/work-hours.test.ts b/apps/web/lib/work-hours.test.ts index 5e69446..7b07830 100644 --- a/apps/web/lib/work-hours.test.ts +++ b/apps/web/lib/work-hours.test.ts @@ -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天)'); diff --git a/apps/web/lib/work-hours.ts b/apps/web/lib/work-hours.ts index 3891ef1..cb40063 100644 --- a/apps/web/lib/work-hours.ts +++ b/apps/web/lib/work-hours.ts @@ -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; diff --git a/docs/decisions.md b/docs/decisions.md index b6d4d42..8c97a16 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -355,3 +355,15 @@ - TestCase 增加 `plannedTestAt`,Bug 增加 `plannedFixAt`,保存为 ISO 时间戳。 **理由**:项目排期需要贴近中国工作日,但研发和线上 Bug 可能确实安排在非工作日处理,所以系统负责提醒,最终是否保存交给用户判断。 + +## 30. 正常工时走中国工作日历,加班单独计入 + +**问题**:接入中国节假日后,如果任务从节假日前开始、节假日后结束,直接按自然时间差会把休息日误算为实际耗时。 + +**决策**: +- `calcWorkHours` 和正常任务的 `calcActualElapsedHours` 都按中国工作日历和工作时段计算。 +- 法定节假日、周末不计入正常工时;调休上班日计入正常工时。 +- AI 预估工时是工作量建议,不按日期跳过;执行预估只有从计划起止时间自动推导时才走工作日历。 +- 加班记录不受工作日历过滤,按加班单填写的开始/结束时间直接计入额外投入。 + +**理由**:正常任务耗时回答“工作时间里实际投入了多少”,加班记录回答“非正常工作时间额外投入了多少”,两条口径分开才能避免休息日被重复或误算。 diff --git a/docs/workflow.md b/docs/workflow.md index 79358b4..08bb0ff 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -233,3 +233,10 @@ Implementation convention: - 非工作日只提示,不阻止保存;调休工作日按工作日提示。 - 测试用例计划测试时间字段为 `plannedTestAt`;Bug 计划修复时间字段为 `plannedFixAt`。 - 测试轮次复制用例时保留计划测试时间、AI 预估和执行预估,清空实际执行记录。 + +## 工时统计口径 + +- 正常任务耗时使用 `calcWorkHours` / `calcActualElapsedHours`,按中国工作日历和 9:00-12:00、13:00-18:00 工作时段计算。 +- 法定节假日和周末不计入正常任务耗时;调休上班日计入正常任务耗时。 +- AI 预估工时和手填执行预估工时本身是小时数,不再按日期过滤;只有从计划起止时间自动推导的执行预估会按工作日历计算。 +- 加班记录使用 `overtime.calcDuration`,按加班单开始/结束时间直接计算,不受节假日过滤。