From e8c0d3c3ebd3bfbe5c16c2f03d5e59ef77299d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 21:00:11 +0200 Subject: [PATCH] fix(api): honor vacation deduction snapshots --- .../src/__tests__/entitlement-router.test.ts | 271 +++++++++++++++--- packages/api/src/router/entitlement.ts | 74 ++++- 2 files changed, 300 insertions(+), 45 deletions(-) diff --git a/packages/api/src/__tests__/entitlement-router.test.ts b/packages/api/src/__tests__/entitlement-router.test.ts index fba64ca..3bd5709 100644 --- a/packages/api/src/__tests__/entitlement-router.test.ts +++ b/packages/api/src/__tests__/entitlement-router.test.ts @@ -197,6 +197,18 @@ describe("entitlement.getBalance", () => { it("counts sick days separately", async () => { 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 = { systemSettings: { findUnique: vi.fn().mockResolvedValue(null), @@ -206,20 +218,7 @@ describe("entitlement.getBalance", () => { update: vi.fn().mockResolvedValue(entitlement), }, vacation: { - findMany: vi - .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, - }, - ]), + findMany: vacationFindMany, }, }; @@ -329,25 +328,26 @@ describe("entitlement.getBalance", () => { }), }, vacation: { - findMany: vi - .fn() - // 2025 holiday context - .mockResolvedValueOnce([]) - // 2025 balance vacations - .mockResolvedValueOnce([ - { - startDate: new Date("2025-06-10T00:00:00.000Z"), - endDate: new Date("2025-06-17T00:00:00.000Z"), - status: "APPROVED", - isHalfDay: false, - }, - ]) - // 2026 holiday context - .mockResolvedValueOnce([]) - // 2026 balance vacations - .mockResolvedValueOnce([]) - // 2026 sick days - .mockResolvedValueOnce([]), + findMany: vi.fn().mockImplementation(async ({ where }: { + where?: { type?: unknown; startDate?: { lte?: Date }; endDate?: { gte?: Date } }; + } = {}) => { + if (where?.type === "SICK" || where?.type === "PUBLIC_HOLIDAY") { + return []; + } + + if (where?.startDate?.lte?.toISOString().startsWith("2025-12-31")) { + return [ + { + startDate: new Date("2025-06-10T00:00:00.000Z"), + endDate: new Date("2025-06-17T00:00:00.000Z"), + status: "APPROVED", + isHalfDay: false, + }, + ]; + } + + return []; + }), }, }; @@ -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 } = {}) => ({ + ...(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", () => { diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts index 9f6fe71..b52a9a8 100644 --- a/packages/api/src/router/entitlement.ts +++ b/packages/api/src/router/entitlement.ts @@ -11,6 +11,9 @@ import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure import { createAuditEntry } from "../lib/audit.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.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 */ 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); } +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>>, +): Promise { + 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. */ @@ -281,7 +318,6 @@ async function syncEntitlement( : entitlement; const yearStart = new Date(`${year}-01-01T00: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({ where: { @@ -291,23 +327,37 @@ async function syncEntitlement( endDate: { gte: yearStart }, 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 pendingDays = 0; + let legacyHolidayContextPromise: Promise>> | null = null; + const getLegacyHolidayContext = async () => { + if (!legacyHolidayContextPromise) { + legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd); + } + return legacyHolidayContextPromise; + }; for (const v of vacations) { - const days = countVacationChargeableDays({ - vacation: v, - periodStart: yearStart, - periodEnd: yearEnd, - countryCode: holidayContext.countryCode, - federalState: holidayContext.federalState, - metroCityName: holidayContext.metroCityName, - calendarHolidayStrings: holidayContext.calendarHolidayStrings, - publicHolidayStrings: holidayContext.publicHolidayStrings, - }); + const days = await calculateEntitlementVacationDays( + yearStart, + yearEnd, + v, + getLegacyHolidayContext, + ); if (v.status === VacationStatus.APPROVED) usedDays += days; else pendingDays += days; }