fix(api): honor vacation deduction snapshots
This commit is contained in:
@@ -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([
|
}
|
||||||
{
|
|
||||||
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
if (where?.startDate?.lte?.toISOString().startsWith("2025-12-31")) {
|
||||||
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
return [
|
||||||
status: "APPROVED",
|
{
|
||||||
isHalfDay: false,
|
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
||||||
},
|
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
||||||
])
|
status: "APPROVED",
|
||||||
// 2026 holiday context
|
isHalfDay: false,
|
||||||
.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", () => {
|
describe("entitlement.getBalanceDetail", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user