Compare commits

..

6 Commits

Author SHA1 Message Date
BOHA
875ef4d79e chore(release): v2.4.47 — KPI cards + Bilance aligned with the print recap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:48:56 +02:00
BOHA
a679f6e8ac fix(attendance): KPI cards + Bilance now use the print-recap formulas
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 <noreply@anthropic.com>
2026-07-08 00:47:26 +02:00
BOHA
3530dd247c chore(release): v2.4.46 — Chybějící hodiny (manko) in the payroll recap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:22:36 +02:00
BOHA
996012e3d3 feat(attendance): 'Chybějící hodiny' (manko) row — real hours below the fond
manko = max(0, fond − (worked_total + dovolená + nemoc + neplacené volno))
using REAL clock time, not the paušál. Supplement next to Přesčas — the
verified binding formulas are untouched.

Why: the paušál Odpracováno can nominally cover the fond while dozens of
real hours are missing (Dominik 6/2026: 21 days × 8 + 8h dovolená = 176 =
fond exactly, thanks to weekend shifts compensating 3 recordless weekdays —
yet real worked time was 136,5h). Přesčas = 0 correctly, and the 31,5h gap
vanished from every total. Now it's a permanent summary line, red+bold when
positive, plus a 'Chybí: Xh' chip on the admin screen cards.

+4 tests incl. the real June dataset as a pinned acceptance case (also
pins noc 6,75 — early shifts starting 05:30/05:45 legitimately count
night minutes before 06:00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:12:20 +02:00
BOHA
3cd7cfcc34 chore(release): v2.4.45 — binding payroll-recap formulas (payslip-verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:03:28 +02:00
BOHA
68d9c224bc 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>
2026-07-07 19:00:54 +02:00
11 changed files with 500 additions and 171 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.44", "version": "2.4.47",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.44", "version": "2.4.47",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.44", "version": "2.4.47",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -347,6 +347,10 @@ exports[`buildUserSectionHtml > produces a stable per-user section (full output
<span class="summary-label">Přesčas:</span> <span class="summary-label">Přesčas:</span>
<span class="summary-value">0,00h</span> <span class="summary-value">0,00h</span>
</div> </div>
<div class="summary-row summary-alert">
<span class="summary-label">Chybějící hodiny:</span>
<span class="summary-value">152,00h</span>
</div>
<div class="summary-row"> <div class="summary-row">
<span class="summary-label">Svátek:</span> <span class="summary-label">Svátek:</span>
<span class="summary-value">0,00h</span> <span class="summary-value">0,00h</span>

View File

@@ -1,175 +1,283 @@
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);
});
it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => {
expect(s.manko).toBe(0);
});
});
describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => {
// Real production month exposing the gap the paušál hides: 21 attended days
// × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally
// compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is
// only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are
// binding), manko must surface the 31,5.
const JUNE = [
W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]),
W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]),
W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]),
W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday
W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]),
W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]),
W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]),
W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon
W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]),
W("2026-06-17", "10:45", "13:15"),
W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]),
W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]),
W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]),
W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]),
V("2026-06-25", 8),
W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]),
W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday
W("2026-06-29", "11:45", "14:30"),
W("2026-06-30", "06:30", "09:15"),
W("2026-06-30", "10:45", "13:30"),
];
const s = computeMzdaSummary(JUNE as never, "2026-06");
it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => {
expect(s.fund).toBe(176);
expect(s.businessDays).toBe(22);
expect(s.odpracovano).toBe(168); // 21 days × 8 flat
expect(s.vacationHours).toBe(8);
expect(s.workedHoursRaw).toBe(136.5);
expect(s.prescas).toBe(0); // (136,5 + 8) 176 < 0 — unchanged formula
expect(s.vcetneSvatku).toBe(168);
expect(s.svatekHours).toBe(0);
expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6.
// 14.6. shift (22:0004:15 minus break = 5:45) + early starts before
// 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15).
expect(s.nightMinutes / 60).toBe(6.75);
});
it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => {
expect(s.manko).toBe(31.5); // 176 (136,5 + 8)
});
it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => {
const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }];
const s2 = computeMzdaSummary(withSick as never, "2026-06");
expect(s2.sickHours).toBe(8);
expect(s2.manko).toBe(23.5); // 31,5 8
});
});
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

@@ -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:0016: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);
});
});

View File

@@ -97,6 +97,7 @@ interface UserTotal {
weekend_hours?: number; weekend_hours?: number;
night_minutes?: number; night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday worked_holiday_hours?: number; // sum of hours worked ON a holiday
manko?: number; // Chybějící hodiny: real clock time below the fond
records?: AttendanceRecord[]; records?: AttendanceRecord[];
} }
@@ -243,6 +244,7 @@ function computeUserTotals(
t.weekend_hours = s.weekendHours; t.weekend_hours = s.weekendHours;
t.night_minutes = s.nightMinutes; t.night_minutes = s.nightMinutes;
t.worked_holiday_hours = s.svatekHours; t.worked_holiday_hours = s.svatekHours;
t.manko = s.manko;
} }
return totals; return totals;
@@ -427,6 +429,10 @@ export function buildUserSectionHtml(
<span class="summary-label">Přesčas:</span> <span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span> <span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
</div> </div>
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
<span class="summary-label">Chybějící hodiny:</span>
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
</div>
<div class="summary-row"> <div class="summary-row">
<span class="summary-label">Svátek:</span> <span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span> <span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
@@ -547,6 +553,9 @@ export function buildPrintHtml(
} }
.user-summary .summary-label { color: #555; } .user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; } .user-summary .summary-value { font-weight: 600; }
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
.user-summary .summary-alert .summary-label,
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
.user-summary .summary-fund { .user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px; border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
} }

View File

@@ -77,7 +77,10 @@ export interface BalancesData {
export interface FundUserData { export interface FundUserData {
name: string; name: string;
worked: number; worked: number;
/** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
covered: number; covered: number;
/** worked + dovolená only — the Přesčas base (print-recap parity). */
covered_prescas: number;
overtime: number; overtime: number;
missing: number; missing: number;
} }

View File

@@ -46,28 +46,29 @@ interface UserTotalData {
prescas?: number; prescas?: number;
svatek?: number; svatek?: number;
worked_holiday_hours?: number; worked_holiday_hours?: number;
/** Chybějící hodiny: real clock time below the fond (approved absences count). */
manko?: number;
} }
/** Fond "used" total mirrors the Fond footer line below: /** Fond coverage — print-recap parity (computeMzdaSummary): real worked
* real_worked + vacation + worked_holiday + free_holiday. */ * 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 { function getFondUsed(data: UserTotalData): number {
const realWorked = data.worked_hours_raw ?? data.worked_hours; const realWorked = data.worked_hours_raw ?? data.worked_hours;
return ( return (
realWorked + realWorked +
(data.vacation_hours ?? 0) + (data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) + (data.sick_hours ?? 0) +
(data.svatek ?? 0) (data.unpaid_hours ?? 0)
); );
} }
/** Maps the Fond delta (used fund) to a ProgressBar color token, mirroring /** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
* the legacy getFundBarBackground conditional: over fund → warning, exactly * hodiny > 0 → error, exactly covered → success. */
* at fund → success, under fund → primary (was the accent gradient). */
function getFundBarColor(data: UserTotalData): ProgressColor { function getFundBarColor(data: UserTotalData): ProgressColor {
const delta = getFondUsed(data) - (data.fund ?? 0); if ((data.prescas ?? 0) > 0) return "warning";
if (delta > 0) return "warning"; if ((data.manko ?? 0) > 0) return "error";
if (delta >= 0) return "success"; return "success";
return "primary";
} }
const PrintIcon = ( const PrintIcon = (
@@ -293,17 +294,24 @@ export default function AttendanceAdmin() {
label={`Nep: ${ut.unpaid_hours}h`} label={`Nep: ${ut.unpaid_hours}h`}
/> />
)} )}
{ut.manko !== undefined && ut.manko > 0 && (
<StatusChip
color="error"
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
/>
)}
</Box> </Box>
{/* 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 && {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 fondUsed = getFondUsed(ut);
const fundVal = ut.fund ?? 0; const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal; const prescas = ut.prescas ?? 0;
const manko = ut.manko ?? 0;
const pct = Math.min( const pct = Math.min(
100, 100,
(fondUsed / (ut.fund || 1)) * 100, (fondUsed / (ut.fund || 1)) * 100,
@@ -325,26 +333,28 @@ export default function AttendanceAdmin() {
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "} Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h {formatHoursDecimal(fundVal * 60)}h
</Typography> </Typography>
{delta > 0 && ( {prescas > 0 && (
<Typography <Typography
variant="caption" variant="caption"
title="Přesčas (worked + dovolená nad fond)"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
color: "warning.main", color: "warning.main",
}} }}
> >
+{formatHoursDecimal(delta * 60)}h +{formatHoursDecimal(prescas * 60)}h
</Typography> </Typography>
)} )}
{delta < 0 && ( {manko > 0 && (
<Typography <Typography
variant="caption" variant="caption"
title="Chybějící hodiny do fondu"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
color: "error.main", color: "error.main",
}} }}
> >
-{formatHoursDecimal(Math.abs(delta) * 60)}h -{formatHoursDecimal(manko * 60)}h
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@@ -251,6 +251,7 @@ export default function AttendanceBalances() {
let totalFund = 0; let totalFund = 0;
let totalWorked = 0; let totalWorked = 0;
let totalCovered = 0; let totalCovered = 0;
let totalCoveredPrescas = 0;
for (const monthData of Object.values(fundData.months)) { for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past // Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund; totalFund += monthData.fund_to_date ?? monthData.fund;
@@ -258,15 +259,20 @@ export default function AttendanceBalances() {
if (us) { if (us) {
totalWorked += us.worked; totalWorked += us.worked;
totalCovered += us.covered; 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( const missing = Math.max(
0, 0,
Math.round((totalFund - totalCovered) * 10) / 10, Math.round((totalFund - totalCovered) * 10) / 10,
); );
const overtime = Math.max( const overtime = Math.max(
0, 0,
Math.round((totalCovered - totalFund) * 10) / 10, Math.round((totalCoveredPrescas - totalFund) * 10) / 10,
); );
return { return {
fund: totalFund, fund: totalFund,

View File

@@ -373,32 +373,58 @@ 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). */ /** Nemoc: sum of sick leave_hours (evidence records). */
freeHolidayHours: number; sickHours: number;
/** Neplacené volno: sum of unpaid leave_hours (evidence records). */
unpaidHours: number;
/**
* Chybějící hodiny (manko):
* max(0, fond (worked_total + dovolená + nemoc + neplacené volno))
* — REAL clock hours below the fond. A supplement next to Přesčas, never a
* replacement: the paušál Odpracováno can nominally cover the fond (weekend
* work compensating missing weekdays) while dozens of real hours are
* missing — e.g. Dominik 6/2026: paušál 168 + 8 dovolená = fond 176, yet
* real worked was 136,5 → manko 31,5. This line makes that visible.
*/
manko: 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 +436,76 @@ 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;
let sickHours = 0;
let unpaidHours = 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;
else if (lt === "sick") sickHours += Number(r.leave_hours) || 8;
else if (lt === "unpaid") unpaidHours += 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);
// Chybějící hodiny: REAL clock time (not the paušál) vs the fond — approved
// absences (dovolená/nemoc/neplacené volno) cover the fond.
const manko = Math.max(
0,
fund - (workedHoursRaw + vacationHours + sickHours + unpaidHours),
);
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), sickHours,
unpaidHours,
manko,
}; };
}; };

View File

@@ -574,6 +574,7 @@ export async function getWorkfund(year: number) {
name: string; name: string;
worked: number; worked: number;
covered: number; covered: number;
covered_prescas: number;
overtime: number; overtime: number;
missing: number; missing: number;
} }
@@ -614,6 +615,7 @@ export async function getWorkfund(year: number) {
name: string; name: string;
worked: number; worked: number;
covered: number; covered: number;
covered_prescas: number;
overtime: number; overtime: number;
missing: number; missing: number;
} }
@@ -624,6 +626,7 @@ export async function getWorkfund(year: number) {
let worked = 0; let worked = 0;
let vacationHours = 0; let vacationHours = 0;
let sickHours = 0; let sickHours = 0;
let unpaidHours = 0;
for (const rec of recs) { for (const rec of recs) {
const lt = (rec.leave_type as string) || "work"; const lt = (rec.leave_type as string) || "work";
if (lt === "work") { if (lt === "work") {
@@ -639,21 +642,35 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8; vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") { } else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8; sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "unpaid") {
unpaidHours += Number(rec.leave_hours) || 8;
} }
// "holiday" is auto-derived from Czech public holidays. // "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 userFund = fundToDate;
const workedRound = Math.round(worked * 10) / 10; const workedRound = Math.round(worked * 10) / 10;
const leaveHours = vacationHours + sickHours; const coveredPrescas = Math.round((worked + vacationHours) * 10) / 10;
const covered = Math.round((worked + leaveHours) * 10) / 10; const covered =
Math.round((worked + vacationHours + sickHours + unpaidHours) * 10) /
10;
const missing = Math.max(0, Math.round((userFund - covered) * 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)] = { monthUsers[String(u.id)] = {
name: `${u.first_name} ${u.last_name}`.trim(), name: `${u.first_name} ${u.last_name}`.trim(),
worked: workedRound, worked: workedRound,
covered, covered,
covered_prescas: coveredPrescas,
overtime, overtime,
missing, missing,
}; };