diff --git a/packages/api/src/__tests__/entitlement-router-auth.test.ts b/packages/api/src/__tests__/entitlement-router-auth.test.ts index 463d91e..a275d34 100644 --- a/packages/api/src/__tests__/entitlement-router-auth.test.ts +++ b/packages/api/src/__tests__/entitlement-router-auth.test.ts @@ -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" }, diff --git a/packages/api/src/__tests__/entitlement-router.test.ts b/packages/api/src/__tests__/entitlement-router.test.ts index 3bd5709..cf93d2a 100644 --- a/packages/api/src/__tests__/entitlement-router.test.ts +++ b/packages/api/src/__tests__/entitlement-router.test.ts @@ -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 } = {}) => ({ + ...(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, diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts index 933dc76..8c01866 100644 --- a/packages/api/src/__tests__/vacation-router.test.ts +++ b/packages/api/src/__tests__/vacation-router.test.ts @@ -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), diff --git a/packages/api/src/lib/vacation-deduction-snapshot.ts b/packages/api/src/lib/vacation-deduction-snapshot.ts new file mode 100644 index 0000000..6391957 --- /dev/null +++ b/packages/api/src/lib/vacation-deduction-snapshot.ts @@ -0,0 +1,120 @@ +import { Prisma, VacationType } from "@capakraken/db"; +import type { TRPCContext } from "../trpc.js"; +import { loadResourceHolidayContext } from "./resource-holiday-context.js"; +import { countVacationChargeableDays } from "./vacation-day-count.js"; + +export const VACATION_BALANCE_TYPES = new Set([ + VacationType.ANNUAL, + VacationType.OTHER, +]); + +export type VacationChargeableInput = { + resourceId: string; + type: VacationType; + startDate: Date; + endDate: Date; + isHalfDay: boolean; +}; + +export type VacationDeductionSnapshot = { + deductedDays: number; + holidayCountryCode: string | null; + holidayCountryName: string | null; + holidayFederalState: string | null; + holidayMetroCityName: string | null; + holidayCalendarDates: string[]; + holidayLegacyPublicHolidayDates: string[]; +}; + +type VacationSnapshotCarrier = { + deductedDays?: number | null; + holidayCountryCode?: string | null; + holidayFederalState?: string | null; + holidayMetroCityName?: string | null; + holidayCalendarDates?: Prisma.JsonValue | null; + holidayLegacyPublicHolidayDates?: Prisma.JsonValue | null; + startDate: Date; + endDate: Date; + isHalfDay: boolean; +}; + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +export function parseVacationSnapshotDateList(value: Prisma.JsonValue | null | undefined): string[] { + return isStringArray(value) ? [...value].sort() : []; +} + +export async function calculateVacationDeductionSnapshot( + db: TRPCContext["db"], + vacation: VacationChargeableInput, +): Promise { + const holidayContext = await loadResourceHolidayContext( + db, + vacation.resourceId, + vacation.startDate, + vacation.endDate, + ); + + return { + deductedDays: countVacationChargeableDays({ + vacation, + countryCode: holidayContext.countryCode, + federalState: holidayContext.federalState, + metroCityName: holidayContext.metroCityName, + calendarHolidayStrings: holidayContext.calendarHolidayStrings, + publicHolidayStrings: holidayContext.publicHolidayStrings, + }), + holidayCountryCode: holidayContext.countryCode ?? null, + holidayCountryName: holidayContext.countryName ?? null, + holidayFederalState: holidayContext.federalState ?? null, + holidayMetroCityName: holidayContext.metroCityName ?? null, + holidayCalendarDates: [...holidayContext.calendarHolidayStrings].sort(), + holidayLegacyPublicHolidayDates: [...holidayContext.publicHolidayStrings].sort(), + }; +} + +export function buildVacationDeductionSnapshotWriteData( + snapshot: VacationDeductionSnapshot, +): { + deductedDays: number; + holidayCountryCode: string | null; + holidayCountryName: string | null; + holidayFederalState: string | null; + holidayMetroCityName: string | null; + holidayCalendarDates: Prisma.InputJsonValue; + holidayLegacyPublicHolidayDates: Prisma.InputJsonValue; +} { + return { + deductedDays: snapshot.deductedDays, + holidayCountryCode: snapshot.holidayCountryCode, + holidayCountryName: snapshot.holidayCountryName, + holidayFederalState: snapshot.holidayFederalState, + holidayMetroCityName: snapshot.holidayMetroCityName, + holidayCalendarDates: snapshot.holidayCalendarDates as unknown as Prisma.InputJsonValue, + holidayLegacyPublicHolidayDates: + snapshot.holidayLegacyPublicHolidayDates as unknown as Prisma.InputJsonValue, + }; +} + +export function countVacationChargeableDaysFromSnapshot( + vacation: VacationSnapshotCarrier, + periodStart?: Date, + periodEnd?: Date, +): number | null { + if (vacation.deductedDays == null) { + return null; + } + + return countVacationChargeableDays({ + vacation, + periodStart, + periodEnd, + countryCode: vacation.holidayCountryCode, + federalState: vacation.holidayFederalState, + metroCityName: vacation.holidayMetroCityName, + calendarHolidayStrings: parseVacationSnapshotDateList(vacation.holidayCalendarDates), + publicHolidayStrings: parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates), + }); +} diff --git a/packages/api/src/router/entitlement-procedure-support.ts b/packages/api/src/router/entitlement-procedure-support.ts index 4e37dbf..ccd9d80 100644 --- a/packages/api/src/router/entitlement-procedure-support.ts +++ b/packages/api/src/router/entitlement-procedure-support.ts @@ -5,8 +5,12 @@ import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; 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"; +import { + countVacationChargeableDaysFromSnapshot, + parseVacationSnapshotDateList, +} from "../lib/vacation-deduction-snapshot.js"; import type { TRPCContext } from "../trpc.js"; +import { buildVacationPreview } from "./vacation-read-support.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; @@ -21,6 +25,37 @@ type EntitlementSnapshot = { type EntitlementReadContext = Pick; type EntitlementWriteContext = Pick; +type EntitlementVacationStatus = "APPROVED" | "PENDING"; + +type EntitlementVacationExplainability = { + type: VacationType; + status: EntitlementVacationStatus; + startDate: string; + endDate: string; + isHalfDay: boolean; + requestedDays: number; + deductedDays: number; + holidayCountryCode: string | null; + holidayCountryName: string | null; + holidayFederalState: string | null; + holidayMetroCityName: string | null; + holidayCalendarDates: string[]; + holidayLegacyPublicHolidayDates: string[]; + holidayDetails: Array<{ + date: string; + source: "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY"; + }>; + holidayContext: { + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; + sources: { + hasCalendarHolidays: boolean; + hasLegacyPublicHolidayEntries: boolean; + }; + }; +}; export const EntitlementBalanceInputSchema = z.object({ resourceId: z.string(), @@ -64,6 +99,22 @@ function mapBalanceDetail(resource: { pendingDays: number; remainingDays: number; sickDays: number; + deductionSummary?: { + formula: string; + approvedVacationCount: number; + pendingVacationCount: number; + approvedRequestedDays: number; + pendingRequestedDays: number; + approvedDeductedDays: number; + pendingDeductedDays: number; + excludedHolidayDates: string[]; + holidayBasisVariants: string[]; + sources: { + hasCalendarHolidays: boolean; + hasLegacyPublicHolidayEntries: boolean; + }; + }; + vacations?: EntitlementVacationExplainability[]; }) { return { resource: resource.displayName, @@ -75,6 +126,8 @@ function mapBalanceDetail(resource: { pending: balance.pendingDays, remaining: balance.remainingDays, sickDays: balance.sickDays, + ...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}), + ...(balance.vacations ? { vacations: balance.vacations } : {}), }; } @@ -84,6 +137,10 @@ function mapYearSummaryDetail( displayName: string; eid: string; chapter: string | null; + countryCode: string | null; + countryName: string | null; + federalState: string | null; + metroCityName: string | null; entitledDays: number; carryoverDays: number; usedDays: number; @@ -107,6 +164,10 @@ function mapYearSummaryDetail( resource: summary.displayName, eid: summary.eid, chapter: summary.chapter ?? null, + countryCode: summary.countryCode ?? null, + countryName: summary.countryName ?? null, + federalState: summary.federalState ?? null, + metroCityName: summary.metroCityName ?? null, year, entitled: summary.entitledDays, carryover: summary.carryoverDays, @@ -175,6 +236,167 @@ async function readBalanceSnapshot( }; } +function toIsoDate(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] { + return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort(); +} + +function filterIsoDatesToRange(isoDates: string[], startDate: Date, endDate: Date): string[] { + const startIso = toIsoDate(startDate); + const endIso = toIsoDate(endDate); + return isoDates.filter((isoDate) => isoDate >= startIso && isoDate <= endIso); +} + +function clampVacationPeriodToYear( + vacation: { startDate: Date; endDate: Date }, + yearStart: Date, + yearEnd: Date, +): { startDate: Date; endDate: Date } { + return { + startDate: vacation.startDate > yearStart ? vacation.startDate : yearStart, + endDate: vacation.endDate < yearEnd ? vacation.endDate : yearEnd, + }; +} + +function formatEntitlementHolidayBasis(vacation: Pick< + EntitlementVacationExplainability, + "holidayCountryName" | "holidayCountryCode" | "holidayFederalState" | "holidayMetroCityName" +>): string { + return [ + vacation.holidayCountryName ?? vacation.holidayCountryCode ?? null, + vacation.holidayFederalState ?? null, + vacation.holidayMetroCityName ?? null, + ].filter((value): value is string => Boolean(value)).join(" / "); +} + +function hasPersistedHolidaySnapshot(vacation: { + deductedDays: number | null; + holidayCountryCode: string | null; + holidayCountryName: string | null; + holidayFederalState: string | null; + holidayMetroCityName: string | null; + holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null; + holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null; +}): boolean { + return vacation.deductedDays != null + || vacation.holidayCountryCode != null + || vacation.holidayCountryName != null + || vacation.holidayFederalState != null + || vacation.holidayMetroCityName != null + || vacation.holidayCalendarDates != null + || vacation.holidayLegacyPublicHolidayDates != null; +} + +function mapEntitlementVacationStatus(status: VacationStatus): EntitlementVacationStatus { + if (status === VacationStatus.APPROVED || status === VacationStatus.PENDING) { + return status; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Unsupported entitlement vacation status: ${status}`, + }); +} + +async function readEntitlementVacationExplainability( + ctx: EntitlementReadContext, + input: z.infer, +): Promise { + const yearStart = new Date(`${input.year}-01-01T00:00:00.000Z`); + const yearEnd = new Date(`${input.year}-12-31T00:00:00.000Z`); + + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + type: { in: BALANCE_TYPES }, + startDate: { lte: yearEnd }, + endDate: { gte: yearStart }, + status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, + }, + select: { + type: true, + startDate: true, + endDate: true, + status: true, + isHalfDay: true, + deductedDays: true, + holidayCountryCode: true, + holidayCountryName: true, + holidayFederalState: true, + holidayMetroCityName: true, + holidayCalendarDates: true, + holidayLegacyPublicHolidayDates: true, + }, + orderBy: [{ startDate: "asc" }, { endDate: "asc" }], + }); + + return Promise.all(vacations.map(async (vacation) => { + const period = clampVacationPeriodToYear(vacation, yearStart, yearEnd); + let vacationHolidayContextPromise: Promise>> | null = null; + const getVacationHolidayContext = async () => { + if (!vacationHolidayContextPromise) { + vacationHolidayContextPromise = loadResourceHolidayContext( + ctx.db, + input.resourceId, + period.startDate, + period.endDate, + ); + } + return vacationHolidayContextPromise; + }; + const fallbackHolidayContext = await getVacationHolidayContext(); + const preview = buildVacationPreview({ + type: vacation.type, + startDate: period.startDate, + endDate: period.endDate, + isHalfDay: vacation.isHalfDay, + holidayContext: hasPersistedHolidaySnapshot(vacation) + ? { + countryCode: vacation.holidayCountryCode ?? fallbackHolidayContext.countryCode ?? null, + countryName: vacation.holidayCountryName ?? fallbackHolidayContext.countryName ?? null, + federalState: vacation.holidayFederalState ?? fallbackHolidayContext.federalState ?? null, + metroCityName: vacation.holidayMetroCityName ?? fallbackHolidayContext.metroCityName ?? null, + calendarHolidayStrings: filterIsoDatesToRange( + parseVacationSnapshotDateList(vacation.holidayCalendarDates), + period.startDate, + period.endDate, + ), + publicHolidayStrings: filterIsoDatesToRange( + parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates), + period.startDate, + period.endDate, + ), + } + : fallbackHolidayContext, + }); + const persistedDeductedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd); + return { + type: vacation.type, + status: mapEntitlementVacationStatus(vacation.status), + startDate: toIsoDate(vacation.startDate), + endDate: toIsoDate(vacation.endDate), + isHalfDay: vacation.isHalfDay, + requestedDays: preview.requestedDays, + deductedDays: persistedDeductedDays ?? preview.deductedDays, + holidayCountryCode: preview.holidayContext.countryCode, + holidayCountryName: preview.holidayContext.countryName, + holidayFederalState: preview.holidayContext.federalState, + holidayMetroCityName: preview.holidayContext.metroCityName, + holidayCalendarDates: preview.holidayDetails + .filter((detail) => detail.source === "CALENDAR" || detail.source === "CALENDAR_AND_LEGACY") + .map((detail) => detail.date), + holidayLegacyPublicHolidayDates: preview.holidayDetails + .filter((detail) => detail.source === "LEGACY_PUBLIC_HOLIDAY" || detail.source === "CALENDAR_AND_LEGACY") + .map((detail) => detail.date), + holidayDetails: preview.holidayDetails, + holidayContext: preview.holidayContext, + }; + })); +} + async function readYearSummarySnapshot( ctx: Pick, input: z.infer, @@ -187,7 +409,13 @@ async function readYearSummarySnapshot( isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, - select: { ...RESOURCE_BRIEF_SELECT, chapter: true }, + select: { + ...RESOURCE_BRIEF_SELECT, + chapter: true, + federalState: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, orderBy: [{ chapter: "asc" }, { displayName: "asc" }], }); @@ -199,6 +427,10 @@ async function readYearSummarySnapshot( displayName: resource.displayName, eid: resource.eid, chapter: resource.chapter, + countryCode: resource.country?.code ?? null, + countryName: resource.country?.name ?? null, + federalState: resource.federalState ?? null, + metroCityName: resource.metroCity?.name ?? null, entitledDays: entitlement.entitledDays, carryoverDays: entitlement.carryoverDays, usedDays: entitlement.usedDays, @@ -406,7 +638,10 @@ export async function getEntitlementBalanceDetail( ctx: EntitlementReadContext, input: z.infer, ) { - const balance = await readBalanceSnapshot(ctx, input); + const [balance, vacations] = await Promise.all([ + readBalanceSnapshot(ctx, input), + readEntitlementVacationExplainability(ctx, input), + ]); const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { displayName: true, eid: true }, @@ -419,7 +654,28 @@ export async function getEntitlementBalanceDetail( }); } - return mapBalanceDetail(resource, balance); + const approvedVacations = vacations.filter((vacation) => vacation.status === VacationStatus.APPROVED); + const pendingVacations = vacations.filter((vacation) => vacation.status === VacationStatus.PENDING); + + return mapBalanceDetail(resource, { + ...balance, + deductionSummary: { + formula: "remaining = entitlement - taken - pending", + approvedVacationCount: approvedVacations.length, + pendingVacationCount: pendingVacations.length, + approvedRequestedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), + pendingRequestedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), + approvedDeductedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), + pendingDeductedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), + excludedHolidayDates: buildEntitlementHolidayDateUnion(vacations), + holidayBasisVariants: [...new Set(vacations.map(formatEntitlementHolidayBasis).filter((value) => value.length > 0))], + sources: { + hasCalendarHolidays: vacations.some((vacation) => vacation.holidayContext.sources.hasCalendarHolidays), + hasLegacyPublicHolidayEntries: vacations.some((vacation) => vacation.holidayContext.sources.hasLegacyPublicHolidayEntries), + }, + }, + vacations, + }); } export async function getEntitlement( diff --git a/packages/api/src/router/vacation-read-support.ts b/packages/api/src/router/vacation-read-support.ts new file mode 100644 index 0000000..abcb771 --- /dev/null +++ b/packages/api/src/router/vacation-read-support.ts @@ -0,0 +1,113 @@ +import { VacationStatus, VacationType } from "@capakraken/db"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; +import { type ResourceHolidayContext } from "../lib/resource-holiday-context.js"; +import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js"; +import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; +import type { TRPCContext } from "../trpc.js"; + +type VacationReadDb = TRPCContext["db"]; + +type VacationPreviewInput = { + type: VacationType; + startDate: Date; + endDate: Date; + isHalfDay: boolean; + holidayContext: ResourceHolidayContext; +}; + +function resolveHolidayDetailSource( + isoDate: string, + holidayContext: ResourceHolidayContext, +): "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY" { + const inCalendar = holidayContext.calendarHolidayStrings.includes(isoDate); + const inLegacy = holidayContext.publicHolidayStrings.includes(isoDate); + + if (inCalendar && inLegacy) { + return "CALENDAR_AND_LEGACY"; + } + + return inCalendar ? "CALENDAR" : "LEGACY_PUBLIC_HOLIDAY"; +} + +export function buildVacationPreview( + input: VacationPreviewInput, +) { + const vacation = { + startDate: input.startDate, + endDate: input.endDate, + isHalfDay: input.isHalfDay, + }; + const requestedDays = countCalendarDaysInPeriod(vacation); + const effectiveDays = VACATION_BALANCE_TYPES.has(input.type) + ? countVacationChargeableDays({ + vacation, + countryCode: input.holidayContext.countryCode, + federalState: input.holidayContext.federalState, + metroCityName: input.holidayContext.metroCityName, + calendarHolidayStrings: input.holidayContext.calendarHolidayStrings, + publicHolidayStrings: input.holidayContext.publicHolidayStrings, + }) + : requestedDays; + const publicHolidayDates = [...new Set([ + ...input.holidayContext.calendarHolidayStrings, + ...input.holidayContext.publicHolidayStrings, + ])].sort(); + + return { + requestedDays, + effectiveDays, + deductedDays: VACATION_BALANCE_TYPES.has(input.type) ? effectiveDays : 0, + publicHolidayDates, + holidayDetails: publicHolidayDates.map((date) => ({ + date, + source: resolveHolidayDetailSource(date, input.holidayContext), + })), + holidayContext: { + countryCode: input.holidayContext.countryCode ?? null, + countryName: input.holidayContext.countryName ?? null, + federalState: input.holidayContext.federalState ?? null, + metroCityName: input.holidayContext.metroCityName ?? null, + sources: { + hasCalendarHolidays: input.holidayContext.calendarHolidayStrings.length > 0, + hasLegacyPublicHolidayEntries: input.holidayContext.publicHolidayStrings.length > 0, + }, + }, + }; +} + +export async function findVacationResourceChapter( + db: VacationReadDb, + resourceId: string, +): Promise { + const resource = await db.resource.findUnique({ + where: { id: resourceId }, + select: { chapter: true }, + }); + + return resource?.chapter ?? null; +} + +export async function listChapterVacationOverlaps( + db: VacationReadDb, + input: { + chapter: string; + resourceId: string; + startDate: Date; + endDate: Date; + }, +) { + return db.vacation.findMany({ + where: { + resource: { chapter: input.chapter }, + resourceId: { not: input.resourceId }, + status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + include: { + resource: { select: RESOURCE_BRIEF_SELECT }, + }, + orderBy: { startDate: "asc" }, + take: 20, + }); +} diff --git a/packages/api/src/router/vacation-read.ts b/packages/api/src/router/vacation-read.ts index 0c6ae7b..6303ce6 100644 --- a/packages/api/src/router/vacation-read.ts +++ b/packages/api/src/router/vacation-read.ts @@ -5,12 +5,12 @@ import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; -import { - VACATION_BALANCE_TYPES, - type VacationChargeableInput, -} from "../lib/vacation-deduction-snapshot.js"; -import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { protectedProcedure, type TRPCContext } from "../trpc.js"; +import { + buildVacationPreview, + findVacationResourceChapter, + listChapterVacationOverlaps, +} from "./vacation-read-support.js"; type VacationReadContext = Pick; @@ -138,53 +138,13 @@ export const vacationReadProcedures = { input.startDate, input.endDate, ); - const vacation: Pick = { + return buildVacationPreview({ + type: input.type, startDate: input.startDate, endDate: input.endDate, isHalfDay: input.isHalfDay ?? false, - }; - const requestedDays = countCalendarDaysInPeriod(vacation); - const effectiveDays = VACATION_BALANCE_TYPES.has(input.type) - ? countVacationChargeableDays({ - vacation, - countryCode: holidayContext.countryCode, - federalState: holidayContext.federalState, - metroCityName: holidayContext.metroCityName, - calendarHolidayStrings: holidayContext.calendarHolidayStrings, - publicHolidayStrings: holidayContext.publicHolidayStrings, - }) - : requestedDays; - const publicHolidayDates = [...new Set([ - ...holidayContext.calendarHolidayStrings, - ...holidayContext.publicHolidayStrings, - ])].sort(); - const holidayDetails = publicHolidayDates.map((date) => ({ - date, - source: - holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date) - ? "CALENDAR_AND_LEGACY" - : holidayContext.calendarHolidayStrings.includes(date) - ? "CALENDAR" - : "LEGACY_PUBLIC_HOLIDAY", - })); - - return { - requestedDays, - effectiveDays, - deductedDays: VACATION_BALANCE_TYPES.has(input.type) ? effectiveDays : 0, - publicHolidayDates, - holidayDetails, - holidayContext: { - countryCode: holidayContext.countryCode ?? null, - countryName: holidayContext.countryName ?? null, - federalState: holidayContext.federalState ?? null, - metroCityName: holidayContext.metroCityName ?? null, - sources: { - hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0, - hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0, - }, - }, - }; + holidayContext, + }); }), list: protectedProcedure @@ -316,27 +276,16 @@ export const vacationReadProcedures = { .query(async ({ ctx, input }) => { await assertCanReadVacationResource(ctx, input.resourceId); - const resource = await ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { chapter: true }, - }); - if (!resource?.chapter) { + const chapter = await findVacationResourceChapter(ctx.db, input.resourceId); + if (!chapter) { return []; } - return ctx.db.vacation.findMany({ - where: { - resource: { chapter: resource.chapter }, - resourceId: { not: input.resourceId }, - status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - }, - orderBy: { startDate: "asc" }, - take: 20, + return listChapterVacationOverlaps(ctx.db, { + chapter, + resourceId: input.resourceId, + startDate: input.startDate, + endDate: input.endDate, }); }), @@ -372,19 +321,11 @@ export const vacationReadProcedures = { }); } - const overlaps = await ctx.db.vacation.findMany({ - where: { - resource: { chapter: resource.chapter }, - resourceId: { not: input.resourceId }, - status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, - startDate: { lte: input.endDate }, - endDate: { gte: input.startDate }, - }, - include: { - resource: { select: RESOURCE_BRIEF_SELECT }, - }, - orderBy: { startDate: "asc" }, - take: 20, + const overlaps = await listChapterVacationOverlaps(ctx.db, { + chapter: resource.chapter, + resourceId: input.resourceId, + startDate: input.startDate, + endDate: input.endDate, }); return mapTeamOverlapDetail({