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:
@@ -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:00–06:00 window. */
|
||||
nightMinutes: number;
|
||||
/** Fond měsíce: Mon–Fri (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: 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).
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user