fix(attendance): payroll recap per accountant spec; multi-shift day rows; shared computation
Rekapitulace (accountant notes, confirmed by owner 2026-07): - Odpracováno = all worked hours EXCEPT hours worked on holidays (was floor(worked/8)×8 — a 3h day printed as 0h) - Vč. svátků a přesčasů = ALL worked hours; line2 − line1 = Svátek (was worked + vacation − 8h×worked-holidays — could print less than actually worked, and vacation inflated it) - Přesčas = max(0, worked − fond), month-level, never negative (was the sub-8h remainder + vacation — printed 3h overtime for a 3h month, 8h overtime for pure vacation, and −6h when a holiday was worked) - Svátek = hours actually WORKED on holiday dates (was 8h per UNworked holiday — opposite meaning) - So/Ne, Práce v noci, Dovolená, Fond unchanged (verified correct) Single shared computeMzdaSummary now feeds BOTH the print and the admin screen (they had drifted: capped vs uncapped holiday adjustment). Print day table: one row PER RECORD — days with two shifts (real in prod data) silently dropped all but the last record from the table while the totals counted them all. +7 spec tests (accountant's own examples); print snapshot updated to the new spec values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -337,7 +337,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
|
||||
<span class="summary-value">16,00h</span>
|
||||
<span class="summary-value">8,00h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Dovolená:</span>
|
||||
@@ -345,7 +345,7 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Přesčas:</span>
|
||||
<span class="summary-value">8,00h</span>
|
||||
<span class="summary-value">0,00h</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="summary-label">Svátek:</span>
|
||||
|
||||
176
src/__tests__/attendance-mzda-summary.test.ts
Normal file
176
src/__tests__/attendance-mzda-summary.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeMzdaSummary } 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.
|
||||
*/
|
||||
|
||||
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,
|
||||
leave_type: "work",
|
||||
});
|
||||
const vacation = (day: string, hours: number) => ({
|
||||
shift_date: `${day}T02:00:00`,
|
||||
leave_type: "vacation",
|
||||
leave_hours: hours,
|
||||
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}`);
|
||||
|
||||
describe("computeMzdaSummary — accountant spec", () => {
|
||||
it("single 3h day: worked 3, no overtime (173h below fond)", () => {
|
||||
const s = computeMzdaSummary(
|
||||
[work("2026-06-02", "09:00", "12:00")],
|
||||
"2026-06",
|
||||
);
|
||||
expect(s.odpracovano).toBe(3);
|
||||
expect(s.vcetneSvatku).toBe(3);
|
||||
expect(s.prescas).toBe(0);
|
||||
expect(s.fund).toBe(176);
|
||||
});
|
||||
|
||||
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("accountant's Svátek example: 10h worked on 6.7. → Svátek 10; excluded from Odpracováno", () => {
|
||||
const julyWorkdays = [
|
||||
"01",
|
||||
"02",
|
||||
"03",
|
||||
"07",
|
||||
"08",
|
||||
"09",
|
||||
"10",
|
||||
"13",
|
||||
"14",
|
||||
"15",
|
||||
"16",
|
||||
"17",
|
||||
"20",
|
||||
"21",
|
||||
"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
|
||||
});
|
||||
|
||||
it("vacation does NOT flow into Přesčas (worked-hours-only rule)", () => {
|
||||
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(
|
||||
[work("2026-06-06", "08:00", "12:00")], // Saturday 4h
|
||||
"2026-06",
|
||||
);
|
||||
expect(s.weekendHours).toBe(4);
|
||||
expect(s.odpracovano).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
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"),
|
||||
];
|
||||
const totalMins = 8 * 60;
|
||||
const html = buildUserSectionHtml(
|
||||
"1",
|
||||
{ name: "Test", minutes: totalMins, 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(/<td>2\. 6\. 2026<\/td>/g) || [];
|
||||
expect(rows.length).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user