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>
This commit is contained in:
124
src/__tests__/attendance-workfund.test.ts
Normal file
124
src/__tests__/attendance-workfund.test.ts
Normal 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:00–16: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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user