Compare commits
2 Commits
3530dd247c
...
875ef4d79e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
875ef4d79e | ||
|
|
a679f6e8ac |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.46",
|
||||
"version": "2.4.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.46",
|
||||
"version": "2.4.47",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.46",
|
||||
"version": "2.4.47",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,10 @@ export interface BalancesData {
|
||||
export interface FundUserData {
|
||||
name: string;
|
||||
worked: number;
|
||||
/** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
|
||||
covered: number;
|
||||
/** worked + dovolená only — the Přesčas base (print-recap parity). */
|
||||
covered_prescas: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
|
||||
@@ -50,26 +50,25 @@ interface UserTotalData {
|
||||
manko?: number;
|
||||
}
|
||||
|
||||
/** Fond "used" total mirrors the Fond footer line below:
|
||||
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||
/** Fond coverage — print-recap parity (computeMzdaSummary): real worked
|
||||
* 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 {
|
||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||
return (
|
||||
realWorked +
|
||||
(data.vacation_hours ?? 0) +
|
||||
(data.worked_holiday_hours ?? 0) +
|
||||
(data.svatek ?? 0)
|
||||
(data.sick_hours ?? 0) +
|
||||
(data.unpaid_hours ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
||||
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
||||
* at fund → success, under fund → primary (was the accent gradient). */
|
||||
/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
|
||||
* hodiny > 0 → error, exactly covered → success. */
|
||||
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||
const delta = getFondUsed(data) - (data.fund ?? 0);
|
||||
if (delta > 0) return "warning";
|
||||
if (delta >= 0) return "success";
|
||||
return "primary";
|
||||
if ((data.prescas ?? 0) > 0) return "warning";
|
||||
if ((data.manko ?? 0) > 0) return "error";
|
||||
return "success";
|
||||
}
|
||||
|
||||
const PrintIcon = (
|
||||
@@ -304,15 +303,15 @@ export default function AttendanceAdmin() {
|
||||
)}
|
||||
</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 &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation +
|
||||
// worked_holiday + free_holiday (everything the user
|
||||
// actually got paid for this month).
|
||||
const fondUsed = getFondUsed(ut);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
const prescas = ut.prescas ?? 0;
|
||||
const manko = ut.manko ?? 0;
|
||||
const pct = Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
@@ -334,26 +333,28 @@ export default function AttendanceAdmin() {
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</Typography>
|
||||
{delta > 0 && (
|
||||
{prescas > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
title="Přesčas (worked + dovolená nad fond)"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
+{formatHoursDecimal(prescas * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
{delta < 0 && (
|
||||
{manko > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
title="Chybějící hodiny do fondu"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
-{formatHoursDecimal(manko * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -251,6 +251,7 @@ export default function AttendanceBalances() {
|
||||
let totalFund = 0;
|
||||
let totalWorked = 0;
|
||||
let totalCovered = 0;
|
||||
let totalCoveredPrescas = 0;
|
||||
for (const monthData of Object.values(fundData.months)) {
|
||||
// Use prorated fund (fund_to_date) for current month, full fund for past
|
||||
totalFund += monthData.fund_to_date ?? monthData.fund;
|
||||
@@ -258,15 +259,20 @@ export default function AttendanceBalances() {
|
||||
if (us) {
|
||||
totalWorked += us.worked;
|
||||
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(
|
||||
0,
|
||||
Math.round((totalFund - totalCovered) * 10) / 10,
|
||||
);
|
||||
const overtime = Math.max(
|
||||
0,
|
||||
Math.round((totalCovered - totalFund) * 10) / 10,
|
||||
Math.round((totalCoveredPrescas - totalFund) * 10) / 10,
|
||||
);
|
||||
return {
|
||||
fund: totalFund,
|
||||
|
||||
@@ -574,6 +574,7 @@ export async function getWorkfund(year: number) {
|
||||
name: string;
|
||||
worked: number;
|
||||
covered: number;
|
||||
covered_prescas: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
@@ -614,6 +615,7 @@ export async function getWorkfund(year: number) {
|
||||
name: string;
|
||||
worked: number;
|
||||
covered: number;
|
||||
covered_prescas: number;
|
||||
overtime: number;
|
||||
missing: number;
|
||||
}
|
||||
@@ -624,6 +626,7 @@ export async function getWorkfund(year: number) {
|
||||
let worked = 0;
|
||||
let vacationHours = 0;
|
||||
let sickHours = 0;
|
||||
let unpaidHours = 0;
|
||||
for (const rec of recs) {
|
||||
const lt = (rec.leave_type as string) || "work";
|
||||
if (lt === "work") {
|
||||
@@ -639,21 +642,35 @@ export async function getWorkfund(year: number) {
|
||||
vacationHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "sick") {
|
||||
sickHours += Number(rec.leave_hours) || 8;
|
||||
} else if (lt === "unpaid") {
|
||||
unpaidHours += Number(rec.leave_hours) || 8;
|
||||
}
|
||||
// "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 workedRound = Math.round(worked * 10) / 10;
|
||||
const leaveHours = vacationHours + sickHours;
|
||||
const covered = Math.round((worked + leaveHours) * 10) / 10;
|
||||
const coveredPrescas = Math.round((worked + vacationHours) * 10) / 10;
|
||||
const covered =
|
||||
Math.round((worked + vacationHours + sickHours + unpaidHours) * 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)] = {
|
||||
name: `${u.first_name} ${u.last_name}`.trim(),
|
||||
worked: workedRound,
|
||||
covered,
|
||||
covered_prescas: coveredPrescas,
|
||||
overtime,
|
||||
missing,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user