fix(api): honor vacation deduction snapshots
This commit is contained in:
@@ -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([
|
||||
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,
|
||||
},
|
||||
])
|
||||
// 2026 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 balance vacations
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 sick days
|
||||
.mockResolvedValueOnce([]),
|
||||
];
|
||||
}
|
||||
|
||||
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<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", () => {
|
||||
|
||||
@@ -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<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.
|
||||
*/
|
||||
@@ -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<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user