fix(attendance): binding payroll-recap formulas, back-computed against a real payslip

Formulas per the owner's verified spec (Dominik Vesecký 5/2026, exact to
a tenth of an hour — built-in acceptance dataset pins every line):

- fond        = (Po-Pá dny mimo svátek) × 8         [was: incl. holidays]
- odpracováno = (dny se záznamem Práce mimo svátek) × 8, paušálně
                [was: actual worked hours minus holiday work]
- přesčas     = max(0, (worked_total + dovolená) − fond)
                [was: worked only, holiday-including fond]
- vč. svátků  = odpracováno + přesčas  [was: worked_total]
- svátek/dovolená/so-ne/noc unchanged (already verified correct)

Expected 5/2026: fond 152 · odpracováno 144 · svátek 8 · dovolená 24 ·
přesčas 17,25→17,3 · vč. 161,25→161,3 · So/Ne 57,25 · noc 36,75 (the
payslip's 31 was the accountant's own error — she missed the 31.5.→1.6.
overnight shift; the spec mandates 36,75).

Two-shift days count once toward odpracováno; 'holiday' placeholder
records stay evidence-only; holiday table remains year-parametric
(fixed dates + computed Easter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-07-07 19:00:54 +02:00
parent 200aa0e1a8
commit 68d9c224bc
2 changed files with 209 additions and 144 deletions

View File

@@ -1,175 +1,219 @@
import { describe, it, expect } from "vitest"; 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"; import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
/** /**
* Payroll recap spec (accountant notes, confirmed by the owner 2026-07): * BINDING payroll-recap formulas (owner spec 2026-07, back-computed against
* - Odpracováno = all worked hours EXCEPT hours worked on holidays * the real payslip of Dominik Vesecký 5/2026 — the dataset below is the
* - Vč. svátků a přesčasů = ALL worked hours (so line2 line1 = Svátek) * built-in acceptance test and MUST match exactly, not approximately):
* - Dovolená = Σ vacation leave_hours (hour-based) *
* - Přesčas = max(0, worked fond) — month-level, never negative * fond = (PoPá dny, které NEJSOU svátek) × 8
* - Svátek = hours actually WORKED on public-holiday dates * odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně
* - So/Ne = worked hours on Sat/Sun * svátek = čisté odpracované hodiny ve dnech svátku
* - Fond = MonFri (holidays included) × 8 h * dovolená = Σ leave_hours (evidence)
* June 2026: 22 workdays, no holidays → fond 176. * přesčas = max(0, (worked_total + dovolená) fond) [měsíční]
* July 2026: 23 MonFri days (6.7. Monday holiday counted; 5.7. is Sunday) → fond 184. * 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:0006: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 = ( const W = (d: string, from: string, to: string, brk?: [string, string]) => ({
day: string, shift_date: `${d}T02:00:00`,
from: string, arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`,
to: string, departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`,
brk?: [string, string], break_start: brk
) => ({ ? brk[0].includes(" ")
shift_date: `${day}T02:00:00`, ? brk[0].replace(" ", "T")
arrival_time: `${day}T${from}:00`, : `${d}T${brk[0]}:00`
departure_time: `${day}T${to}:00`, : null,
break_start: brk ? `${day}T${brk[0]}:00` : null, break_end: brk
break_end: brk ? `${day}T${brk[1]}:00` : null, ? brk[1].includes(" ")
? brk[1].replace(" ", "T")
: `${d}T${brk[1]}:00`
: null,
leave_type: "work", leave_type: "work",
}); });
const vacation = (day: string, hours: number) => ({ const V = (d: string, h: number) => ({
shift_date: `${day}T02:00:00`, shift_date: `${d}T02:00:00`,
leave_type: "vacation", leave_type: "vacation",
leave_hours: hours, leave_hours: h,
arrival_time: null, arrival_time: null,
departure_time: null, departure_time: null,
break_start: null, break_start: null,
break_end: null, break_end: null,
}); });
const JUNE_WORKDAYS = [ /** The exact dataset from the spec (times with a space carry a full date). */
"01", const DOMINIK_MAY_2026 = [
"02", W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
"03", W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
"04", "2026-05-04 01:52:00",
"05", "2026-05-04 02:22:00",
"08", ]),
"09", V("2026-05-05", 8),
"10", V("2026-05-06", 8),
"11", V("2026-05-07", 8),
"12", W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
"15", "2026-05-11 01:52:00",
"16", "2026-05-11 02:22:00",
"17", ]),
"18", W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
"19", W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
"22", W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
"23", W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
"24", W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
"25", W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
"26", "2026-05-18 02:00:00",
"29", "2026-05-18 02:30:00",
"30", ]),
].map((d) => `2026-06-${d}`); 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", () => { describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => {
it("single 3h day: worked 3, no overtime (173h below fond)", () => { const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
it("Fond měsíce = 152 (19 PoPá 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( const s = computeMzdaSummary(
[work("2026-06-02", "09:00", "12:00")], [W("2026-06-02", "10:45", "15:30")] as never,
"2026-06", "2026-06",
); );
expect(s.odpracovano).toBe(3); expect(s.odpracovano).toBe(8);
expect(s.vcetneSvatku).toBe(3); expect(s.workedHoursRaw).toBe(4.75);
expect(s.prescas).toBe(0); expect(s.prescas).toBe(0); // (4,75 + 0) 176 < 0
expect(s.fund).toBe(176); expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
}); });
it("accountant's Přesčas example: +3h one day, 2h elsewhere → 1h overtime", () => { it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
const recs = JUNE_WORKDAYS.slice(0, 20).map((d) => const s = computeMzdaSummary(
work(d, "08:00", "16:30", ["12:00", "12:30"]), [
); // 20 × 8h W("2026-06-02", "06:00", "10:00"),
recs.push(work("2026-06-29", "08:00", "14:00")); // 6h (2h short) W("2026-06-02", "14:00", "18:00"),
recs.push(work("2026-06-30", "08:00", "19:30", ["12:00", "12:30"])); // 11h (+3h) ] as never,
const s = computeMzdaSummary(recs, "2026-06"); "2026-06",
expect(s.vcetneSvatku).toBe(177); );
expect(s.prescas).toBe(1); 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", () => { it("dovolená covers the fond in Přesčas", () => {
const julyWorkdays = [ // 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
const recs: unknown[] = [];
const days = [
"01", "01",
"02", "02",
"03", "03",
"07", "04",
"05",
"08", "08",
"09", "09",
"10", "10",
"13", "11",
"14", "12",
"15", "15",
"16", "16",
"17", "17",
"20", "18",
"21", "19",
"22", "22",
"23", "23",
"24", "24",
"27", "25",
"28", ].map((d) => `2026-06-${d}`);
"29", for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152
"30", recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14));
"31", const s = computeMzdaSummary(recs as never, "2026-06");
].map((d) => `2026-07-${d}`); expect(s.vacationHours).toBe(30);
const recs = julyWorkdays.map((d) => expect(s.prescas).toBe(6);
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 MonFri 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
}); });
it("vacation does NOT flow into Přesčas (worked-hours-only rule)", () => { it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
const recs: ReturnType<typeof work | typeof vacation>[] =
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", () => {
const s = computeMzdaSummary( const s = computeMzdaSummary(
[work("2026-06-06", "08:00", "12:00")], // Saturday 4h [W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
"2026-06", "2026-07",
); );
expect(s.weekendHours).toBe(4); expect(s.svatekHours).toBe(10);
expect(s.odpracovano).toBe(4); expect(s.odpracovano).toBe(0);
expect(s.fund).toBe(176); // 23 MonFri 6.7. holiday = 22 × 8
}); });
}); });
describe("print day table — multiple records on one day", () => { describe("print day table — multiple records on one day", () => {
it("renders one row per record (two shifts are both visible)", () => { it("renders one row per record (two shifts are both visible)", () => {
const records = [ const records = [
work("2026-06-02", "06:00", "10:00"), W("2026-06-02", "06:00", "10:00"),
work("2026-06-02", "14:00", "18:00"), W("2026-06-02", "14:00", "18:00"),
]; ];
const totalMins = 8 * 60;
const html = buildUserSectionHtml( const html = buildUserSectionHtml(
"1", "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, { 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("06:00");
expect(html).toContain("14:00"); expect(html).toContain("14:00");
// Two rows dated 2. 6.
const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || []; const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || [];
expect(rows.length).toBe(2); expect(rows.length).toBe(2);
}); });

View File

@@ -373,32 +373,44 @@ export const calculateFreeHolidayHours = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mzda rekapitulace (single source — the print AND the admin screen consume // Mzda rekapitulace (single source — the print AND the admin screen consume
// this, so the two can never drift again; spec confirmed with the accountant // this, so the two can never drift again). Formulas are BINDING per the
// 2026-07, see the summary lines below) // 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 = (PoPá 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:0006: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 { 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; 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; 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; vcetneSvatku: number;
/** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */ /** Dovolená: sum of vacation leave_hours (hour-based, partial days allowed). */
vacationHours: number; 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; prescas: number;
/** Svátek: hours actually WORKED on public-holiday dates. */ /** Svátek: net hours actually WORKED on public-holiday dates. */
svatekHours: number; 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; weekendHours: number;
/** Práce v noci: worked minutes in the 22:0006:00 window. */ /** Práce v noci: minutes in 22:0006:00 minus breaks in the window. */
nightMinutes: number; nightMinutes: number;
/** Fond měsíce: MonFri (public holidays included) × 8 h. */ /** Fond měsíce: MonFri days that are NOT public holidays × 8 h. */
fund: number; fund: number;
/** MonFri non-holiday day count backing the fond. */
businessDays: number; 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. */ /** Compute the payroll recap for one user's month of records. */
@@ -410,54 +422,63 @@ export const computeMzdaSummary = (
const yr = parseInt(yearStr, 10); const yr = parseInt(yearStr, 10);
const mo = parseInt(monthNumStr, 10); const mo = parseInt(monthNumStr, 10);
const holidayDates = holidaysInMonth(yr, mo); const holidaySet = new Set(holidaysInMonth(yr, mo));
const holidaySet = new Set(holidayDates);
let workedMinutes = 0; let workedMinutes = 0;
let svatekMinutes = 0; let svatekMinutes = 0;
let weekendMinutes = 0; let weekendMinutes = 0;
let nightMinutes = 0; let nightMinutes = 0;
let vacationHours = 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<string>();
for (const r of records) { for (const r of records) {
const lt = r.leave_type || "work"; const lt = r.leave_type || "work";
if (lt !== "work") { if (lt !== "work") {
// "holiday" placeholder records are evidence-only — skip everywhere.
if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8; if (lt === "vacation") vacationHours += Number(r.leave_hours) || 8;
continue; continue;
} }
const m = calculateWorkMinutesPrint(r); const m = calculateWorkMinutesPrint(r);
workedMinutes += m; workedMinutes += m;
const day = normalizeDateStr(r.shift_date); 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; if (isWeekendDate(day)) weekendMinutes += m;
nightMinutes += calculateNightMinutes(r); nightMinutes += calculateNightMinutes(r);
} }
// Fond: MonFri × 8 h; public holidays stay in the count (they are paid via // Fond: MonFri days that are NOT public holidays × 8 h.
// the fond — an unworked holiday earns its 8 h, see freeHolidayHours).
let businessDays = 0; let businessDays = 0;
const cur = new Date(yr, mo - 1, 1); const cur = new Date(yr, mo - 1, 1);
while (cur.getMonth() === mo - 1) { while (cur.getMonth() === mo - 1) {
const dow = cur.getDay(); 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); cur.setDate(cur.getDate() + 1);
} }
const fund = businessDays * 8; const fund = businessDays * 8;
const workedHoursRaw = workedMinutes / 60; const workedHoursRaw = workedMinutes / 60;
const svatekHours = svatekMinutes / 60; const odpracovano = workDays.size * 8;
const prescas = Math.max(0, workedHoursRaw + vacationHours - fund);
return { return {
workedHoursRaw, workedHoursRaw,
odpracovano: workedHoursRaw - svatekHours, odpracovano,
vcetneSvatku: workedHoursRaw, vcetneSvatku: odpracovano + prescas,
vacationHours, vacationHours,
prescas: Math.max(0, workedHoursRaw - fund), prescas,
svatekHours, svatekHours: svatekMinutes / 60,
weekendHours: weekendMinutes / 60, weekendHours: weekendMinutes / 60,
nightMinutes, nightMinutes,
fund, fund,
businessDays, businessDays,
freeHolidayHours: calculateFreeHolidayHours(records, holidayDates),
}; };
}; };