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:
BOHA
2026-07-07 18:00:17 +02:00
parent 89656ac2c6
commit e3b414d22c
4 changed files with 353 additions and 211 deletions

View File

@@ -370,3 +370,94 @@ export const calculateFreeHolidayHours = (
}
return free;
};
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
export interface MzdaSummary {
/** All worked hours in the month: arrival→departure minus recorded breaks. */
workedHoursRaw: number;
/** Odpracováno: all worked hours EXCEPT hours worked on public holidays. */
odpracovano: number;
/** Odpracováno včetně svátků a přesčasů: ALL worked hours (line2 line1 = Svátek). */
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). */
prescas: number;
/** Svátek: hours actually WORKED on public-holiday dates. */
svatekHours: number;
/** So/Ne: worked hours on Saturdays and Sundays. */
weekendHours: number;
/** Práce v noci: worked minutes in the 22:0006:00 window. */
nightMinutes: number;
/** Fond měsíce: MonFri (public holidays included) × 8 h. */
fund: 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. */
export const computeMzdaSummary = (
records: AttendanceRecord[],
monthStr: string,
): MzdaSummary => {
const [yearStr, monthNumStr] = monthStr.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthNumStr, 10);
const holidayDates = holidaysInMonth(yr, mo);
const holidaySet = new Set(holidayDates);
let workedMinutes = 0;
let svatekMinutes = 0;
let weekendMinutes = 0;
let nightMinutes = 0;
let vacationHours = 0;
for (const r of records) {
const lt = r.leave_type || "work";
if (lt !== "work") {
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 (isWeekendDate(day)) weekendMinutes += m;
nightMinutes += calculateNightMinutes(r);
}
// Fond: MonFri × 8 h; public holidays stay in the count (they are paid via
// the fond — an unworked holiday earns its 8 h, see freeHolidayHours).
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++;
cur.setDate(cur.getDate() + 1);
}
const fund = businessDays * 8;
const workedHoursRaw = workedMinutes / 60;
const svatekHours = svatekMinutes / 60;
return {
workedHoursRaw,
odpracovano: workedHoursRaw - svatekHours,
vcetneSvatku: workedHoursRaw,
vacationHours,
prescas: Math.max(0, workedHoursRaw - fund),
svatekHours,
weekendHours: weekendMinutes / 60,
nightMinutes,
fund,
businessDays,
freeHolidayHours: calculateFreeHolidayHours(records, holidayDates),
};
};