From a679f6e8ac2213a1b6117d9b4bba20d009790092 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 8 Jul 2026 00:47:26 +0200 Subject: [PATCH] fix(attendance): KPI cards + Bilance now use the print-recap formulas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification found three divergences from the binding print recap: - getWorkfund (Bilance page): overtime included nemoc (print Přesčas is worked + dovolená only) and missing ignored neplacené volno (print Chybějící hodiny counts every approved absence). Both now match; the response carries covered (manko base) + covered_prescas (Přesčas base) and the page's year aggregate sums them separately. - AttendanceAdmin fond bar: used = worked + dovolená + worked_holiday + svatek — after v2.4.45 the last two fields hold the SAME value, so holiday work counted twice (regression). Coverage is now worked + all approved absences, and the ± badges show the exact print values (ut.prescas / ut.manko) instead of a derived delta; bar color follows Přesčas/Chybějící hodiny. - Fond itself was already aligned everywhere (non-holiday weekdays × 8, prorated for the running month). +1 route-parity test (real DB): worked 80 + dov 24 + nem 16 + nepl 8 vs fond 176 → covered 128, covered_prescas 104, missing 48, overtime 0. Co-Authored-By: Claude Fable 5 --- src/__tests__/attendance-workfund.test.ts | 124 ++++++++++++++++++++++ src/admin/lib/queries/attendance.ts | 3 + src/admin/pages/AttendanceAdmin.tsx | 41 +++---- src/admin/pages/AttendanceBalances.tsx | 8 +- src/services/attendance.service.ts | 23 +++- 5 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/attendance-workfund.test.ts diff --git a/src/__tests__/attendance-workfund.test.ts b/src/__tests__/attendance-workfund.test.ts new file mode 100644 index 0000000..5a1b164 --- /dev/null +++ b/src/__tests__/attendance-workfund.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import prisma from "../config/database"; +import { getWorkfund } from "../services/attendance.service"; + +/** + * Print-recap parity for the Bilance page's monthly fund overview + * (getWorkfund), per the binding formulas (computeMzdaSummary): + * overtime (Přesčas) = max(0, worked + dovolená − fond) + * missing (Chybějící hodiny) = max(0, fond − (worked + dovolená + nemoc + * + neplacené volno)) + * The two coverage bases differ deliberately: only dovolená earns toward + * overtime, every approved absence covers the fond. + */ + +const USERNAME = "wf_parity_test_user"; +let userId: number; + +const june = (day: string) => new Date(`2026-06-${day}T00:00:00Z`); +const dt = (day: string, hm: string) => new Date(`2026-06-${day}T${hm}:00`); + +beforeAll(async () => { + await prisma.attendance.deleteMany({ + where: { users: { username: USERNAME } }, + }); + await prisma.users.deleteMany({ where: { username: USERNAME } }); + + // getAttendanceUsers includes only users whose role carries + // attendance.record — the seeded admin role has every permission. + const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } }); + if (!adminRole) throw new Error("Admin role not found — seed the DB first"); + const user = await prisma.users.create({ + data: { + username: USERNAME, + email: `${USERNAME}@test.local`, + password_hash: + "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", + first_name: "Workfund", + last_name: "Parity", + is_active: true, + role_id: adminRole.id, + }, + }); + userId = user.id; + + // June 2026 (fond 176 = 22 non-holiday weekdays × 8): + // 10 work days of 8h net (08:00–16:30, 30min break) → worked 80 + const workDays = ["01", "02", "03", "04", "05", "08", "09", "10", "11", "12"]; + for (const d of workDays) { + await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: june(d), + arrival_time: dt(d, "08:00"), + departure_time: dt(d, "16:30"), + break_start: dt(d, "12:00"), + break_end: dt(d, "12:30"), + leave_type: "work", + }, + }); + } + // dovolená 24, nemoc 16, neplacené volno 8 + const leaves: Array<[string, string, number]> = [ + ["15", "vacation", 8], + ["16", "vacation", 8], + ["17", "vacation", 8], + ["18", "sick", 8], + ["19", "sick", 8], + ["22", "unpaid", 8], + ]; + for (const [d, type, hours] of leaves) { + await prisma.attendance.create({ + data: { + user_id: userId, + shift_date: june(d), + leave_type: type as never, + leave_hours: hours, + }, + }); + } +}); + +afterAll(async () => { + await prisma.attendance.deleteMany({ where: { user_id: userId } }); + await prisma.users.deleteMany({ where: { id: userId } }); +}); + +describe("getWorkfund — print-recap parity", () => { + it("computes overtime from worked+dovolená and missing from all approved absences", async () => { + const data = await getWorkfund(2026); + // getWorkfund's future-year early return types months as a bare {} — + // narrow to the populated shape (2026 is never a future year here). + const months = data.months as Record< + string, + { + fund: number; + users: Record< + string, + { + worked: number; + covered: number; + covered_prescas: number; + overtime: number; + missing: number; + } + >; + } + >; + const juneData = months["6"]; + expect(juneData).toBeTruthy(); + expect(juneData.fund).toBe(176); + + const us = juneData.users[String(userId)]; + expect(us).toBeTruthy(); + expect(us.worked).toBe(80); + // covered (manko base): 80 + 24 + 16 + 8 + expect(us.covered).toBe(128); + // covered_prescas (Přesčas base): 80 + 24 — sick/unpaid excluded + expect(us.covered_prescas).toBe(104); + // Chybějící hodiny: 176 − 128 + expect(us.missing).toBe(48); + // Přesčas: max(0, 104 − 176) + expect(us.overtime).toBe(0); + }); +}); diff --git a/src/admin/lib/queries/attendance.ts b/src/admin/lib/queries/attendance.ts index 981d3e3..735a1e5 100644 --- a/src/admin/lib/queries/attendance.ts +++ b/src/admin/lib/queries/attendance.ts @@ -77,7 +77,10 @@ export interface BalancesData { export interface FundUserData { name: string; worked: number; + /** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */ covered: number; + /** worked + dovolená only — the Přesčas base (print-recap parity). */ + covered_prescas: number; overtime: number; missing: number; } diff --git a/src/admin/pages/AttendanceAdmin.tsx b/src/admin/pages/AttendanceAdmin.tsx index e1bcdb2..ad6edc8 100644 --- a/src/admin/pages/AttendanceAdmin.tsx +++ b/src/admin/pages/AttendanceAdmin.tsx @@ -50,26 +50,25 @@ interface UserTotalData { manko?: number; } -/** Fond "used" total mirrors the Fond footer line below: - * real_worked + vacation + worked_holiday + free_holiday. */ +/** Fond coverage — print-recap parity (computeMzdaSummary): real worked + * hours + every approved absence (dovolená + nemoc + neplacené volno). + * This is the base the Chybějící hodiny line measures against. */ function getFondUsed(data: UserTotalData): number { const realWorked = data.worked_hours_raw ?? data.worked_hours; return ( realWorked + (data.vacation_hours ?? 0) + - (data.worked_holiday_hours ?? 0) + - (data.svatek ?? 0) + (data.sick_hours ?? 0) + + (data.unpaid_hours ?? 0) ); } -/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring - * the legacy getFundBarBackground conditional: over fund → warning, exactly - * at fund → success, under fund → primary (was the accent gradient). */ +/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící + * hodiny > 0 → error, exactly covered → success. */ function getFundBarColor(data: UserTotalData): ProgressColor { - const delta = getFondUsed(data) - (data.fund ?? 0); - if (delta > 0) return "warning"; - if (delta >= 0) return "success"; - return "primary"; + if ((data.prescas ?? 0) > 0) return "warning"; + if ((data.manko ?? 0) > 0) return "error"; + return "success"; } const PrintIcon = ( @@ -304,15 +303,15 @@ export default function AttendanceAdmin() { )} - {/* Fond usage */} + {/* Fond usage — the ± badges are the PRINT recap values + (Přesčas / Chybějící hodiny), so the card and the + printed sheet can never disagree. */} {ut.fund !== null && (() => { - // Fond "used" = real_worked + vacation + - // worked_holiday + free_holiday (everything the user - // actually got paid for this month). const fondUsed = getFondUsed(ut); const fundVal = ut.fund ?? 0; - const delta = fondUsed - fundVal; + const prescas = ut.prescas ?? 0; + const manko = ut.manko ?? 0; const pct = Math.min( 100, (fondUsed / (ut.fund || 1)) * 100, @@ -334,26 +333,28 @@ export default function AttendanceAdmin() { Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "} {formatHoursDecimal(fundVal * 60)}h - {delta > 0 && ( + {prescas > 0 && ( - +{formatHoursDecimal(delta * 60)}h + +{formatHoursDecimal(prescas * 60)}h )} - {delta < 0 && ( + {manko > 0 && ( - -{formatHoursDecimal(Math.abs(delta) * 60)}h + -{formatHoursDecimal(manko * 60)}h )} diff --git a/src/admin/pages/AttendanceBalances.tsx b/src/admin/pages/AttendanceBalances.tsx index 78d37da..b63014b 100644 --- a/src/admin/pages/AttendanceBalances.tsx +++ b/src/admin/pages/AttendanceBalances.tsx @@ -251,6 +251,7 @@ export default function AttendanceBalances() { let totalFund = 0; let totalWorked = 0; let totalCovered = 0; + let totalCoveredPrescas = 0; for (const monthData of Object.values(fundData.months)) { // Use prorated fund (fund_to_date) for current month, full fund for past totalFund += monthData.fund_to_date ?? monthData.fund; @@ -258,15 +259,20 @@ export default function AttendanceBalances() { if (us) { totalWorked += us.worked; totalCovered += us.covered; + // Older cached payloads may lack covered_prescas — fall back to + // covered (the pre-parity behavior) rather than dropping to 0. + totalCoveredPrescas += us.covered_prescas ?? us.covered; } } + // Print-recap parity: missing (Chybějící hodiny) counts every approved + // absence; overtime (Přesčas) counts only worked + dovolená. const missing = Math.max( 0, Math.round((totalFund - totalCovered) * 10) / 10, ); const overtime = Math.max( 0, - Math.round((totalCovered - totalFund) * 10) / 10, + Math.round((totalCoveredPrescas - totalFund) * 10) / 10, ); return { fund: totalFund, diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index 6a17a04..ef05eee 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -574,6 +574,7 @@ export async function getWorkfund(year: number) { name: string; worked: number; covered: number; + covered_prescas: number; overtime: number; missing: number; } @@ -614,6 +615,7 @@ export async function getWorkfund(year: number) { name: string; worked: number; covered: number; + covered_prescas: number; overtime: number; missing: number; } @@ -624,6 +626,7 @@ export async function getWorkfund(year: number) { let worked = 0; let vacationHours = 0; let sickHours = 0; + let unpaidHours = 0; for (const rec of recs) { const lt = (rec.leave_type as string) || "work"; if (lt === "work") { @@ -639,21 +642,35 @@ export async function getWorkfund(year: number) { vacationHours += Number(rec.leave_hours) || 8; } else if (lt === "sick") { sickHours += Number(rec.leave_hours) || 8; + } else if (lt === "unpaid") { + unpaidHours += Number(rec.leave_hours) || 8; } // "holiday" is auto-derived from Czech public holidays. } + // Print-recap parity (computeMzdaSummary — the binding formulas): + // overtime (Přesčas) = max(0, worked + dovolená − fond) + // missing (Chybějící hodiny) = max(0, fond − (worked + dovolená + // + nemoc + neplacené volno)) + // The two coverage sets differ deliberately: only dovolená earns + // toward overtime, but every approved absence covers the fond. const userFund = fundToDate; const workedRound = Math.round(worked * 10) / 10; - const leaveHours = vacationHours + sickHours; - const covered = Math.round((worked + leaveHours) * 10) / 10; + const coveredPrescas = Math.round((worked + vacationHours) * 10) / 10; + const covered = + Math.round((worked + vacationHours + sickHours + unpaidHours) * 10) / + 10; const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); - const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); + const overtime = Math.max( + 0, + Math.round((coveredPrescas - userFund) * 10) / 10, + ); monthUsers[String(u.id)] = { name: `${u.first_name} ${u.last_name}`.trim(), worked: workedRound, covered, + covered_prescas: coveredPrescas, overtime, missing, };