Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd7cfcc34 | ||
|
|
68d9c224bc |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.44",
|
"version": "2.4.45",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.44",
|
"version": "2.4.45",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.44",
|
"version": "2.4.45",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,44 +1,168 @@
|
|||||||
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 = (Po–Pá 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 = Mon–Fri (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 Mon–Fri 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:00–06: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). */
|
||||||
|
const DOMINIK_MAY_2026 = [
|
||||||
|
W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
|
||||||
|
W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
|
||||||
|
"2026-05-04 01:52:00",
|
||||||
|
"2026-05-04 02:22:00",
|
||||||
|
]),
|
||||||
|
V("2026-05-05", 8),
|
||||||
|
V("2026-05-06", 8),
|
||||||
|
V("2026-05-07", 8),
|
||||||
|
W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
|
||||||
|
"2026-05-11 01:52:00",
|
||||||
|
"2026-05-11 02:22:00",
|
||||||
|
]),
|
||||||
|
W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
|
||||||
|
W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
|
||||||
|
W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
|
||||||
|
W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
|
||||||
|
W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
|
||||||
|
"2026-05-18 02:00:00",
|
||||||
|
"2026-05-18 02:30:00",
|
||||||
|
]),
|
||||||
|
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 — built-in acceptance dataset (Dominik 5/2026)", () => {
|
||||||
|
const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
|
||||||
|
|
||||||
|
it("Fond měsíce = 152 (19 Po–Pá 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(
|
||||||
|
[W("2026-06-02", "10:45", "15:30")] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(4.75);
|
||||||
|
expect(s.prescas).toBe(0); // (4,75 + 0) − 176 < 0
|
||||||
|
expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
|
||||||
|
const s = computeMzdaSummary(
|
||||||
|
[
|
||||||
|
W("2026-06-02", "06:00", "10:00"),
|
||||||
|
W("2026-06-02", "14:00", "18:00"),
|
||||||
|
] as never,
|
||||||
|
"2026-06",
|
||||||
|
);
|
||||||
|
expect(s.odpracovano).toBe(8);
|
||||||
|
expect(s.workedHoursRaw).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dovolená covers the fond in Přesčas", () => {
|
||||||
|
// 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
|
||||||
|
const recs: unknown[] = [];
|
||||||
|
const days = [
|
||||||
"01",
|
"01",
|
||||||
"02",
|
"02",
|
||||||
"03",
|
"03",
|
||||||
@@ -58,118 +182,38 @@ const JUNE_WORKDAYS = [
|
|||||||
"23",
|
"23",
|
||||||
"24",
|
"24",
|
||||||
"25",
|
"25",
|
||||||
"26",
|
].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));
|
||||||
].map((d) => `2026-06-${d}`);
|
const s = computeMzdaSummary(recs as never, "2026-06");
|
||||||
|
expect(s.vacationHours).toBe(30);
|
||||||
|
expect(s.prescas).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
describe("computeMzdaSummary — accountant spec", () => {
|
it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
|
||||||
it("single 3h day: worked 3, no overtime (173h below fond)", () => {
|
|
||||||
const s = computeMzdaSummary(
|
const s = computeMzdaSummary(
|
||||||
[work("2026-06-02", "09:00", "12:00")],
|
[W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
|
||||||
"2026-06",
|
"2026-07",
|
||||||
);
|
);
|
||||||
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.svatekHours).toBe(10);
|
||||||
expect(s.vcetneSvatku).toBe(186); // all worked hours
|
expect(s.odpracovano).toBe(0);
|
||||||
expect(s.odpracovano).toBe(176); // minus the holiday-worked 10h
|
expect(s.fund).toBe(176); // 23 Mon–Fri − 6.7. holiday = 22 × 8
|
||||||
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", () => {
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = (Po–Pá 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:00–06: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:00–06:00 window. */
|
/** Práce v noci: minutes in 22:00–06:00 minus breaks in the window. */
|
||||||
nightMinutes: number;
|
nightMinutes: number;
|
||||||
/** Fond měsíce: Mon–Fri (public holidays included) × 8 h. */
|
/** Fond měsíce: Mon–Fri days that are NOT public holidays × 8 h. */
|
||||||
fund: number;
|
fund: number;
|
||||||
|
/** Mon–Fri 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: Mon–Fri × 8 h; public holidays stay in the count (they are paid via
|
// Fond: Mon–Fri 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),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user