fix(api): honor vacation deduction snapshots

This commit is contained in:
2026-03-31 21:00:11 +02:00
parent a490d68a3b
commit e8c0d3c3eb
2 changed files with 300 additions and 45 deletions
@@ -197,6 +197,18 @@ describe("entitlement.getBalance", () => {
it("counts sick days separately", async () => { it("counts sick days separately", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 }); const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 });
const vacationFindMany = vi.fn().mockImplementation(async ({ where }: { where?: { type?: unknown } } = {}) => {
if (where?.type === "SICK") {
return [
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
];
}
return [];
});
const db = { const db = {
systemSettings: { systemSettings: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
@@ -206,20 +218,7 @@ describe("entitlement.getBalance", () => {
update: vi.fn().mockResolvedValue(entitlement), update: vi.fn().mockResolvedValue(entitlement),
}, },
vacation: { vacation: {
findMany: vi findMany: vacationFindMany,
.fn()
// Public holiday vacations for holiday context
.mockResolvedValueOnce([])
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
.mockResolvedValueOnce([
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
]),
}, },
}; };
@@ -329,25 +328,26 @@ describe("entitlement.getBalance", () => {
}), }),
}, },
vacation: { vacation: {
findMany: vi findMany: vi.fn().mockImplementation(async ({ where }: {
.fn() where?: { type?: unknown; startDate?: { lte?: Date }; endDate?: { gte?: Date } };
// 2025 holiday context } = {}) => {
.mockResolvedValueOnce([]) if (where?.type === "SICK" || where?.type === "PUBLIC_HOLIDAY") {
// 2025 balance vacations return [];
.mockResolvedValueOnce([ }
if (where?.startDate?.lte?.toISOString().startsWith("2025-12-31")) {
return [
{ {
startDate: new Date("2025-06-10T00:00:00.000Z"), startDate: new Date("2025-06-10T00:00:00.000Z"),
endDate: new Date("2025-06-17T00:00:00.000Z"), endDate: new Date("2025-06-17T00:00:00.000Z"),
status: "APPROVED", status: "APPROVED",
isHalfDay: false, isHalfDay: false,
}, },
]) ];
// 2026 holiday context }
.mockResolvedValueOnce([])
// 2026 balance vacations return [];
.mockResolvedValueOnce([]) }),
// 2026 sick days
.mockResolvedValueOnce([]),
}, },
}; };
@@ -366,6 +366,211 @@ describe("entitlement.getBalance", () => {
}), }),
); );
}); });
it("deducts only the non-holiday days from mixed vacation ranges", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockImplementation(async ({ data }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: unknown } } = {}) => {
if (where?.type === "PUBLIC_HOLIDAY" || where?.type === "SICK") {
return [];
}
return [
{
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
},
];
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.usedDays).toBe(2);
expect(result.remainingDays).toBe(28);
});
it("prefers persisted holiday snapshots over the current holiday context", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
const resourceFindUnique = vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
...(select?.userId ? { userId: "user_1" } : {}),
...(select?.federalState ? { federalState: "BY" } : {}),
...(select?.country ? { country: { code: "DE" } } : {}),
...(select?.metroCity ? { metroCity: { name: "Augsburg" } } : {}),
}));
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: resourceFindUnique,
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Hohes Friedensfest",
isRecurringAnnual: true,
},
],
},
]),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
update: vi.fn().mockImplementation(async ({ data }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi
.fn()
.mockResolvedValueOnce([
{
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
deductedDays: 1,
holidayCountryCode: "DE",
holidayFederalState: "BY",
holidayMetroCityName: "Augsburg",
holidayCalendarDates: [],
holidayLegacyPublicHolidayDates: [],
},
])
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
expect(result.usedDays).toBe(1);
expect(result.remainingDays).toBe(29);
expect(db.holidayCalendar.findMany).not.toHaveBeenCalled();
});
it("falls back to the current holiday context for legacy vacations without snapshots", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_aug",
federalState: "BY",
country: { code: "DE" },
metroCity: { name: "Augsburg" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_augsburg",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Hohes Friedensfest",
isRecurringAnnual: true,
},
],
},
]),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
update: vi.fn().mockImplementation(async ({ data }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi
.fn()
.mockResolvedValueOnce([
{
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
deductedDays: null,
holidayCountryCode: null,
holidayFederalState: null,
holidayMetroCityName: null,
holidayCalendarDates: null,
holidayLegacyPublicHolidayDates: null,
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
expect(result.usedDays).toBe(0);
expect(result.remainingDays).toBe(30);
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
});
}); });
describe("entitlement.getBalanceDetail", () => { describe("entitlement.getBalanceDetail", () => {
+62 -12
View File
@@ -11,6 +11,9 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
import {
countVacationChargeableDaysFromSnapshot,
} from "../lib/vacation-deduction-snapshot.js";
/** Types that consume from annual leave balance */ /** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
@@ -230,6 +233,40 @@ function calculateCarryoverDays(entitlement: {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays); return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
} }
async function calculateEntitlementVacationDays(
yearStart: Date,
yearEnd: Date,
vacation: {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
deductedDays: number | null;
holidayCountryCode: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null;
},
getLegacyHolidayContext: () => Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>>,
): Promise<number> {
const persistedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
if (persistedDays !== null) {
return persistedDays;
}
const holidayContext = await getLegacyHolidayContext();
return countVacationChargeableDays({
vacation,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
}
/** /**
* Recompute used/pending days from actual vacation records and update the cached values. * Recompute used/pending days from actual vacation records and update the cached values.
*/ */
@@ -281,7 +318,6 @@ async function syncEntitlement(
: entitlement; : entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`); const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`); const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
const vacations = await db.vacation.findMany({ const vacations = await db.vacation.findMany({
where: { where: {
@@ -291,23 +327,37 @@ async function syncEntitlement(
endDate: { gte: yearStart }, endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
}, },
select: { startDate: true, endDate: true, status: true, isHalfDay: true }, select: {
startDate: true,
endDate: true,
status: true,
isHalfDay: true,
deductedDays: true,
holidayCountryCode: true,
holidayFederalState: true,
holidayMetroCityName: true,
holidayCalendarDates: true,
holidayLegacyPublicHolidayDates: true,
},
}); });
let usedDays = 0; let usedDays = 0;
let pendingDays = 0; let pendingDays = 0;
let legacyHolidayContextPromise: Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | null = null;
const getLegacyHolidayContext = async () => {
if (!legacyHolidayContextPromise) {
legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
}
return legacyHolidayContextPromise;
};
for (const v of vacations) { for (const v of vacations) {
const days = countVacationChargeableDays({ const days = await calculateEntitlementVacationDays(
vacation: v, yearStart,
periodStart: yearStart, yearEnd,
periodEnd: yearEnd, v,
countryCode: holidayContext.countryCode, getLegacyHolidayContext,
federalState: holidayContext.federalState, );
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
if (v.status === VacationStatus.APPROVED) usedDays += days; if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days; else pendingDays += days;
} }