diff --git a/src/__tests__/attendance-mzda-summary.test.ts b/src/__tests__/attendance-mzda-summary.test.ts index 7d4b97a..ae3fe4f 100644 --- a/src/__tests__/attendance-mzda-summary.test.ts +++ b/src/__tests__/attendance-mzda-summary.test.ts @@ -1,175 +1,219 @@ import { describe, it, expect } from "vitest"; -import { computeMzdaSummary } from "../admin/utils/attendanceHelpers"; +import { + computeMzdaSummary, + formatHoursDecimal1, +} from "../admin/utils/attendanceHelpers"; import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin"; /** - * Payroll recap spec (accountant notes, confirmed by the owner 2026-07): - * - Odpracováno = all worked hours EXCEPT hours worked on holidays - * - Vč. svátků a přesčasů = ALL worked hours (so line2 − line1 = Svátek) - * - Dovolená = Σ vacation leave_hours (hour-based) - * - Přesčas = max(0, worked − fond) — month-level, never negative - * - Svátek = hours actually WORKED on public-holiday dates - * - So/Ne = worked hours on Sat/Sun - * - Fond = Mon–Fri (holidays included) × 8 h - * June 2026: 22 workdays, no holidays → fond 176. - * July 2026: 23 Mon–Fri days (6.7. Monday holiday counted; 5.7. is Sunday) → fond 184. + * BINDING payroll-recap formulas (owner spec 2026-07, back-computed against + * the real payslip of Dominik Vesecký 5/2026 — the dataset below is the + * built-in acceptance test and MUST match exactly, not approximately): + * + * fond = (Po–Pá dny, které NEJSOU svátek) × 8 + * odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně + * svátek = čisté odpracované hodiny ve dnech svátku + * dovolená = Σ leave_hours (evidence) + * přesčas = max(0, (worked_total + dovolená) − fond) [měsíční] + * vč. svátků = odpracováno + přesčas + * so/ne = čisté hodiny směn začínajících v So/Ne (celá směna) + * noc = průnik s 22:00–06:00 minus pauzy v pásmu, ke dni začátku + * + * Expected 5/2026 (holidays 1.5. + 8.5.): fond 152 · odpracováno 144 · + * svátek 8 · dovolená 24 · přesčas 17,25 (=17,3 na desetiny) · vč. 161,25 + * (=161,3) · So/Ne 57,25 · noc 36,75 (payslip said 31 — accountant's own + * error, she missed the 31.5.→1.6. overnight shift; 36,75 is CORRECT). */ -const work = ( - day: string, - from: string, - to: string, - brk?: [string, string], -) => ({ - shift_date: `${day}T02:00:00`, - arrival_time: `${day}T${from}:00`, - departure_time: `${day}T${to}:00`, - break_start: brk ? `${day}T${brk[0]}:00` : null, - break_end: brk ? `${day}T${brk[1]}:00` : null, +const W = (d: string, from: string, to: string, brk?: [string, string]) => ({ + shift_date: `${d}T02:00:00`, + arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`, + departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`, + break_start: brk + ? brk[0].includes(" ") + ? brk[0].replace(" ", "T") + : `${d}T${brk[0]}:00` + : null, + break_end: brk + ? brk[1].includes(" ") + ? brk[1].replace(" ", "T") + : `${d}T${brk[1]}:00` + : null, leave_type: "work", }); -const vacation = (day: string, hours: number) => ({ - shift_date: `${day}T02:00:00`, +const V = (d: string, h: number) => ({ + shift_date: `${d}T02:00:00`, leave_type: "vacation", - leave_hours: hours, + leave_hours: h, arrival_time: null, departure_time: null, break_start: null, break_end: null, }); -const JUNE_WORKDAYS = [ - "01", - "02", - "03", - "04", - "05", - "08", - "09", - "10", - "11", - "12", - "15", - "16", - "17", - "18", - "19", - "22", - "23", - "24", - "25", - "26", - "29", - "30", -].map((d) => `2026-06-${d}`); +/** The exact dataset from the spec (times with a space carry a full date). */ +const DOMINIK_MAY_2026 = [ + W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]), + W("2026-05-03", "21:30", "2026-05-04 06:15:00", [ + "2026-05-04 01:52:00", + "2026-05-04 02:22:00", + ]), + V("2026-05-05", 8), + V("2026-05-06", 8), + V("2026-05-07", 8), + W("2026-05-10", "21:15", "2026-05-11 06:30:00", [ + "2026-05-11 01:52:00", + "2026-05-11 02:22:00", + ]), + W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]), + W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]), + W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]), + W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]), + W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]), + W("2026-05-17", "21:30", "2026-05-18 06:30:00", [ + "2026-05-18 02:00:00", + "2026-05-18 02:30:00", + ]), + W("2026-05-19", "09:30", "17:00", ["13:15", "13:45"]), + W("2026-05-20", "08:30", "16:30", ["12:30", "13:00"]), + W("2026-05-21", "09:45", "15:15"), + W("2026-05-22", "10:45", "15:30"), + W("2026-05-24", "17:45", "2026-05-25 06:00:00", [ + "23:45", + "2026-05-25 00:45:00", + ]), + W("2026-05-26", "09:00", "14:30"), + W("2026-05-27", "10:00", "15:30"), + W("2026-05-28", "08:00", "15:45", ["11:45", "12:15"]), + W("2026-05-29", "08:15", "15:00", ["11:30", "12:00"]), + W("2026-05-31", "19:45", "2026-06-01 04:15:00", [ + "2026-06-01 00:00:00", + "2026-06-01 00:30:00", + ]), +]; -describe("computeMzdaSummary — accountant spec", () => { - it("single 3h day: worked 3, no overtime (173h below fond)", () => { +describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => { + const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05"); + + it("Fond měsíce = 152 (19 Po–Pá dnů mimo svátky 1.5. a 8.5.)", () => { + expect(s.businessDays).toBe(19); + expect(s.fund).toBe(152); + }); + + it("Odpracováno = 144 (18 pracovních dnů mimo svátek × 8, paušálně)", () => { + expect(s.odpracovano).toBe(144); + }); + + it("Svátek = 8 (skutečné hodiny odpracované 1. 5.)", () => { + expect(s.svatekHours).toBe(8); + }); + + it("Dovolená = 24", () => { + expect(s.vacationHours).toBe(24); + }); + + it("worked_total = 145,25 (145:15 čistého času)", () => { + expect(s.workedHoursRaw).toBe(145.25); + }); + + it("Přesčas = 17,25 → zobrazeno 17,3 ((145,25 + 24) − 152)", () => { + expect(s.prescas).toBe(17.25); + expect(formatHoursDecimal1(s.prescas * 60)).toBe("17,3"); + }); + + it("Odpracováno včetně svátků a přesčasů = 161,25 → zobrazeno 161,3 (144 + 17,25)", () => { + expect(s.vcetneSvatku).toBe(161.25); + expect(formatHoursDecimal1(s.vcetneSvatku * 60)).toBe("161,3"); + }); + + it("So/Ne = 57,25 (6 směn začínajících v So/Ne, celé směny)", () => { + expect(s.weekendHours).toBe(57.25); + }); + + it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => { + expect(s.nightMinutes / 60).toBe(36.75); + }); +}); + +describe("computeMzdaSummary — formula edge cases", () => { + it("Odpracováno is a flat 8h per day: a lone 4:45 day counts as 8", () => { const s = computeMzdaSummary( - [work("2026-06-02", "09:00", "12:00")], + [W("2026-06-02", "10:45", "15:30")] as never, "2026-06", ); - expect(s.odpracovano).toBe(3); - expect(s.vcetneSvatku).toBe(3); - expect(s.prescas).toBe(0); - expect(s.fund).toBe(176); + expect(s.odpracovano).toBe(8); + expect(s.workedHoursRaw).toBe(4.75); + expect(s.prescas).toBe(0); // (4,75 + 0) − 176 < 0 + expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas }); - it("accountant's Přesčas example: +3h one day, −2h elsewhere → 1h overtime", () => { - const recs = JUNE_WORKDAYS.slice(0, 20).map((d) => - work(d, "08:00", "16:30", ["12:00", "12:30"]), - ); // 20 × 8h - recs.push(work("2026-06-29", "08:00", "14:00")); // 6h (2h short) - recs.push(work("2026-06-30", "08:00", "19:30", ["12:00", "12:30"])); // 11h (+3h) - const s = computeMzdaSummary(recs, "2026-06"); - expect(s.vcetneSvatku).toBe(177); - expect(s.prescas).toBe(1); + it("two shifts on one day count that day ONCE (8h, not 16h)", () => { + const s = computeMzdaSummary( + [ + W("2026-06-02", "06:00", "10:00"), + W("2026-06-02", "14:00", "18:00"), + ] as never, + "2026-06", + ); + expect(s.odpracovano).toBe(8); + expect(s.workedHoursRaw).toBe(8); }); - it("accountant's Svátek example: 10h worked on 6.7. → Svátek 10; excluded from Odpracováno", () => { - const julyWorkdays = [ + it("dovolená covers the fond in Přesčas", () => { + // 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas. + const recs: unknown[] = []; + const days = [ "01", "02", "03", - "07", + "04", + "05", "08", "09", "10", - "13", - "14", + "11", + "12", "15", "16", "17", - "20", - "21", + "18", + "19", "22", "23", "24", - "27", - "28", - "29", - "30", - "31", - ].map((d) => `2026-07-${d}`); - const recs = julyWorkdays.map((d) => - work(d, "08:00", "16:30", ["12:00", "12:30"]), - ); // 22 × 8h - recs.push(work("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])); // holiday, 10h - const s = computeMzdaSummary(recs, "2026-07"); - expect(s.fund).toBe(184); // 23 Mon–Fri days incl. the Monday holiday - expect(s.svatekHours).toBe(10); - expect(s.vcetneSvatku).toBe(186); // all worked hours - expect(s.odpracovano).toBe(176); // minus the holiday-worked 10h - expect(s.prescas).toBe(2); // max(0, 186 − 184) — never negative + "25", + ].map((d) => `2026-06-${d}`); + for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152 + recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14)); + const s = computeMzdaSummary(recs as never, "2026-06"); + expect(s.vacationHours).toBe(30); + expect(s.prescas).toBe(6); }); - it("vacation does NOT flow into Přesčas (worked-hours-only rule)", () => { - const recs: ReturnType[] = - JUNE_WORKDAYS.slice(0, 21).map((d) => - work(d, "08:00", "16:30", ["12:00", "12:30"]), - ); // 21 × 8 = 168 - recs.push(vacation("2026-06-30", 8)); - const s = computeMzdaSummary(recs, "2026-06"); - expect(s.vacationHours).toBe(8); - expect(s.prescas).toBe(0); // 168 worked < 176 fond — vacation doesn't count - }); - - it("partial-day vacation next to a work shift on the same day (5h work + 3h vacation)", () => { - const recs = [ - work("2026-06-02", "08:00", "13:00"), // 5h - vacation("2026-06-02", 3), - ]; - const s = computeMzdaSummary(recs, "2026-06"); - expect(s.odpracovano).toBe(5); - expect(s.vacationHours).toBe(3); - }); - - it("weekend hours count into So/Ne and into worked totals", () => { + it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => { const s = computeMzdaSummary( - [work("2026-06-06", "08:00", "12:00")], // Saturday 4h - "2026-06", + [W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h + "2026-07", ); - expect(s.weekendHours).toBe(4); - expect(s.odpracovano).toBe(4); + expect(s.svatekHours).toBe(10); + expect(s.odpracovano).toBe(0); + expect(s.fund).toBe(176); // 23 Mon–Fri − 6.7. holiday = 22 × 8 }); }); describe("print day table — multiple records on one day", () => { it("renders one row per record (two shifts are both visible)", () => { const records = [ - work("2026-06-02", "06:00", "10:00"), - work("2026-06-02", "14:00", "18:00"), + W("2026-06-02", "06:00", "10:00"), + W("2026-06-02", "14:00", "18:00"), ]; - const totalMins = 8 * 60; const html = buildUserSectionHtml( "1", - { name: "Test", minutes: totalMins, vacation_hours: 0, records } as never, + { name: "Test", minutes: 480, vacation_hours: 0, records } as never, { month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never, ); - // Both shifts appear, each with its own arrival time. expect(html).toContain("06:00"); expect(html).toContain("14:00"); - // Two rows dated 2. 6. const rows = html.match(/2\. 6\. 2026<\/td>/g) || []; expect(rows.length).toBe(2); }); diff --git a/src/admin/utils/attendanceHelpers.ts b/src/admin/utils/attendanceHelpers.ts index 9a17f66..6e0ce04 100644 --- a/src/admin/utils/attendanceHelpers.ts +++ b/src/admin/utils/attendanceHelpers.ts @@ -373,32 +373,44 @@ export const calculateFreeHolidayHours = ( // --------------------------------------------------------------------------- // Mzda rekapitulace (single source — the print AND the admin screen consume -// this, so the two can never drift again; spec confirmed with the accountant -// 2026-07, see the summary lines below) +// this, so the two can never drift again). Formulas are BINDING per the +// accountant-verified spec (2026-07, back-computed to the tenth of an hour +// against a real payslip — Dominik Vesecký 5/2026; the built-in dataset test +// in attendance-mzda-summary.test.ts pins every line). Do not "improve" them: +// fond = (Po–Pá dny, které NEJSOU svátek) × 8 +// odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8 — FLAT +// 8 h per day regardless of the shift's real length; real +// deviations settle monthly via Přesčas +// svátek = skutečné čisté hodiny odpracované ve dnech svátku +// dovolená = Σ leave_hours (evidence only, never from timestamps) +// přesčas = max(0, (worked_total + dovolená) − fond) [monthly] +// vč. svátků = odpracováno + přesčas (plain sum, NOT from timestamps) +// so/ne = čisté hodiny směn ZAČÍNAJÍCÍCH v So/Ne (celá směna) +// noc = průnik směny s 22:00–06:00 minus pauzy v pásmu; celá směna +// patří dni svého ZAČÁTKU (i přes hranici měsíce) // --------------------------------------------------------------------------- export interface MzdaSummary { - /** All worked hours in the month: arrival→departure minus recorded breaks. */ + /** worked_total: all net worked hours (arrival→departure minus breaks). */ workedHoursRaw: number; - /** Odpracováno: all worked hours EXCEPT hours worked on public holidays. */ + /** Odpracováno: non-holiday calendar days with a work record × 8 h flat. */ odpracovano: number; - /** Odpracováno včetně svátků a přesčasů: ALL worked hours (line2 − line1 = Svátek). */ + /** Odpracováno včetně svátků a přesčasů = odpracovano + prescas. */ vcetneSvatku: number; /** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */ vacationHours: number; - /** Přesčas: worked hours above the monthly fund — max(0, worked − fond). */ + /** Přesčas: max(0, (worked_total + dovolená) − fond) — monthly, never daily. */ prescas: number; - /** Svátek: hours actually WORKED on public-holiday dates. */ + /** Svátek: net hours actually WORKED on public-holiday dates. */ svatekHours: number; - /** So/Ne: worked hours on Saturdays and Sundays. */ + /** So/Ne: net hours of shifts whose START day is Sat/Sun (whole shift). */ weekendHours: number; - /** Práce v noci: worked minutes in the 22:00–06:00 window. */ + /** Práce v noci: minutes in 22:00–06:00 minus breaks in the window. */ nightMinutes: number; - /** Fond měsíce: Mon–Fri (public holidays included) × 8 h. */ + /** Fond měsíce: Mon–Fri days that are NOT public holidays × 8 h. */ fund: number; + /** Mon–Fri non-holiday day count backing the fond. */ businessDays: number; - /** 8 h per holiday the user did NOT work (fond is covered by these). */ - freeHolidayHours: number; } /** Compute the payroll recap for one user's month of records. */ @@ -410,54 +422,63 @@ export const computeMzdaSummary = ( const yr = parseInt(yearStr, 10); const mo = parseInt(monthNumStr, 10); - const holidayDates = holidaysInMonth(yr, mo); - const holidaySet = new Set(holidayDates); + const holidaySet = new Set(holidaysInMonth(yr, mo)); let workedMinutes = 0; let svatekMinutes = 0; let weekendMinutes = 0; let nightMinutes = 0; let vacationHours = 0; + // Distinct non-holiday calendar days (start day) with a "Práce" record — + // two shifts on one day still count that day once. + const workDays = new Set(); for (const r of records) { const lt = r.leave_type || "work"; if (lt !== "work") { + // "holiday" placeholder records are evidence-only — skip everywhere. if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8; continue; } const m = calculateWorkMinutesPrint(r); workedMinutes += m; const day = normalizeDateStr(r.shift_date); - if (holidaySet.has(day)) svatekMinutes += m; + if (holidaySet.has(day)) { + svatekMinutes += m; + } else if (day) { + workDays.add(day); + } if (isWeekendDate(day)) weekendMinutes += m; nightMinutes += calculateNightMinutes(r); } - // Fond: Mon–Fri × 8 h; public holidays stay in the count (they are paid via - // the fond — an unworked holiday earns its 8 h, see freeHolidayHours). + // Fond: Mon–Fri days that are NOT public holidays × 8 h. let businessDays = 0; const cur = new Date(yr, mo - 1, 1); while (cur.getMonth() === mo - 1) { const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) businessDays++; + if (dow !== 0 && dow !== 6) { + const dayStr = `${yr}-${String(mo).padStart(2, "0")}-${String(cur.getDate()).padStart(2, "0")}`; + if (!holidaySet.has(dayStr)) businessDays++; + } cur.setDate(cur.getDate() + 1); } const fund = businessDays * 8; const workedHoursRaw = workedMinutes / 60; - const svatekHours = svatekMinutes / 60; + const odpracovano = workDays.size * 8; + const prescas = Math.max(0, workedHoursRaw + vacationHours - fund); return { workedHoursRaw, - odpracovano: workedHoursRaw - svatekHours, - vcetneSvatku: workedHoursRaw, + odpracovano, + vcetneSvatku: odpracovano + prescas, vacationHours, - prescas: Math.max(0, workedHoursRaw - fund), - svatekHours, + prescas, + svatekHours: svatekMinutes / 60, weekendHours: weekendMinutes / 60, nightMinutes, fund, businessDays, - freeHolidayHours: calculateFreeHolidayHours(records, holidayDates), }; };