feat(api): explain holiday-aware vacation deductions
This commit is contained in:
@@ -141,6 +141,22 @@ describe("entitlement router authorization", () => {
|
||||
pending: 0,
|
||||
remaining: 25,
|
||||
sickDays: 0,
|
||||
deductionSummary: {
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 0,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 0,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 0,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: [],
|
||||
holidayBasisVariants: [],
|
||||
sources: {
|
||||
hasCalendarHolidays: false,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
vacations: [],
|
||||
});
|
||||
expect(resourceFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "res_2" },
|
||||
|
||||
@@ -617,8 +617,119 @@ describe("entitlement.getBalanceDetail", () => {
|
||||
pending: 0.5,
|
||||
remaining: 28.5,
|
||||
sickDays: 1,
|
||||
deductionSummary: {
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 0,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 0,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 0,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: [],
|
||||
holidayBasisVariants: [],
|
||||
sources: {
|
||||
hasCalendarHolidays: false,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
vacations: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes holiday-adjusted vacation breakdown for explainability", async () => {
|
||||
const entitlement = sampleEntitlement({ carryoverDays: 1, usedDays: 1, pendingDays: 0 });
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
|
||||
...(select?.userId ? { userId: "user_1" } : {}),
|
||||
...(select?.displayName ? { displayName: "Alice Example" } : {}),
|
||||
...(select?.eid ? { eid: "EMP-001" } : {}),
|
||||
})),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string | { in?: string[] } } } = {}) => {
|
||||
if (where?.type === "SICK") {
|
||||
return [];
|
||||
}
|
||||
if (typeof where?.type === "object" && Array.isArray(where.type.in)) {
|
||||
return [
|
||||
{
|
||||
type: "ANNUAL",
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-02T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
deductedDays: 1,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: ["2026-05-01"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
|
||||
|
||||
expect(result.deductionSummary).toEqual({
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 1,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 2,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 1,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: ["2026-05-01"],
|
||||
holidayBasisVariants: ["Germany / BY"],
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
});
|
||||
expect(result.vacations).toEqual([
|
||||
{
|
||||
type: "ANNUAL",
|
||||
status: "APPROVED",
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-05-02",
|
||||
isHalfDay: false,
|
||||
requestedDays: 2,
|
||||
deductedDays: 1,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: ["2026-05-01"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
holidayDetails: [
|
||||
{ date: "2026-05-01", source: "CALENDAR" },
|
||||
],
|
||||
holidayContext: {
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: null,
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||
@@ -813,8 +924,24 @@ describe("entitlement.bulkSet", () => {
|
||||
describe("entitlement.getYearSummary", () => {
|
||||
it("returns summary for all active resources (manager role)", async () => {
|
||||
const resources = [
|
||||
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
|
||||
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
eid: "alice",
|
||||
chapter: "VFX",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
eid: "bob",
|
||||
chapter: "Animation",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
];
|
||||
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
|
||||
const db = {
|
||||
@@ -845,6 +972,12 @@ describe("entitlement.getYearSummary", () => {
|
||||
expect(result[0]).toHaveProperty("resourceId");
|
||||
expect(result[0]).toHaveProperty("displayName");
|
||||
expect(result[0]).toHaveProperty("remainingDays");
|
||||
expect(result[0]).toMatchObject({
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by chapter when provided", async () => {
|
||||
@@ -881,8 +1014,24 @@ describe("entitlement.getYearSummary", () => {
|
||||
describe("entitlement.getYearSummaryDetail", () => {
|
||||
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
|
||||
const resources = [
|
||||
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
|
||||
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice Example",
|
||||
eid: "EMP-001",
|
||||
chapter: "Delivery",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob Example",
|
||||
eid: "EMP-002",
|
||||
chapter: "CGI",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
];
|
||||
const db = {
|
||||
systemSettings: {
|
||||
@@ -931,6 +1080,10 @@ describe("entitlement.getYearSummaryDetail", () => {
|
||||
resource: "Alice Example",
|
||||
eid: "EMP-001",
|
||||
chapter: "Delivery",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
year: 2026,
|
||||
entitled: 28,
|
||||
carryover: 0,
|
||||
|
||||
@@ -110,6 +110,13 @@ const sampleVacation = {
|
||||
note: "Summer vacation",
|
||||
isHalfDay: false,
|
||||
halfDayPart: null,
|
||||
deductedDays: null,
|
||||
holidayCountryCode: null,
|
||||
holidayCountryName: null,
|
||||
holidayFederalState: null,
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: null,
|
||||
holidayLegacyPublicHolidayDates: null,
|
||||
requestedById: "user_1",
|
||||
approvedById: null,
|
||||
approvedAt: null,
|
||||
@@ -636,6 +643,70 @@ describe("vacation router", () => {
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps mixed vacation ranges chargeable when only some days are holidays", async () => {
|
||||
const createdVacation = {
|
||||
...sampleVacation,
|
||||
startDate: new Date("2026-11-14T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-16T00:00:00.000Z"),
|
||||
};
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-11-14T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-16T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.effectiveDays).toBe(2);
|
||||
expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
deductedDays: 2,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: "Muenchen",
|
||||
holidayCalendarDates: ["2026-11-15"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRequest", () => {
|
||||
@@ -833,6 +904,9 @@ describe("vacation router", () => {
|
||||
data: expect.objectContaining({
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
deductedDays: 5,
|
||||
holidayCalendarDates: [],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -865,6 +939,53 @@ describe("vacation router", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects approval when the current holiday context reduces the request to zero days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
}),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||
"Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
|
||||
);
|
||||
expect(db.vacation.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids regular users from approving", async () => {
|
||||
const db = {};
|
||||
const caller = createProtectedCaller(db);
|
||||
@@ -974,11 +1095,33 @@ describe("vacation router", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
{ id: "vac_2", resourceId: "res_2" },
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
|
||||
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
return [];
|
||||
}
|
||||
if (where?.status === VacationStatus.PENDING) {
|
||||
return [
|
||||
{
|
||||
id: "vac_1",
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-05T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
{
|
||||
id: "vac_2",
|
||||
resourceId: "res_2",
|
||||
type: VacationType.OTHER,
|
||||
startDate: new Date("2026-06-08T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-08T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
@@ -989,11 +1132,12 @@ describe("vacation router", () => {
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||
|
||||
expect(result.approved).toBe(2);
|
||||
expect(db.vacation.updateMany).toHaveBeenCalledWith(
|
||||
expect(db.vacation.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { in: ["vac_1", "vac_2"] } },
|
||||
where: { id: "vac_1" },
|
||||
data: expect.objectContaining({
|
||||
status: VacationStatus.APPROVED,
|
||||
deductedDays: 5,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1005,10 +1149,25 @@ describe("vacation router", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
|
||||
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
return [];
|
||||
}
|
||||
if (where?.status === VacationStatus.PENDING) {
|
||||
return [
|
||||
{
|
||||
id: "vac_1",
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
|
||||
Reference in New Issue
Block a user