feat(api): explain holiday-aware vacation deductions

This commit is contained in:
2026-03-31 22:42:00 +02:00
parent 8acfbf8c3e
commit cb363ca5b3
7 changed files with 857 additions and 99 deletions
@@ -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),
@@ -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>([
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<VacationDeductionSnapshot> {
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),
});
}
@@ -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<TRPCContext, "db" | "dbUser">;
type EntitlementWriteContext = Pick<TRPCContext, "db" | "dbUser">;
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<typeof EntitlementBalanceInputSchema>,
): Promise<EntitlementVacationExplainability[]> {
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<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | 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<TRPCContext, "db">,
input: z.infer<typeof EntitlementYearSummaryInputSchema>,
@@ -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<typeof EntitlementBalanceInputSchema>,
) {
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(
@@ -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<string | null> {
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,
});
}
+21 -80
View File
@@ -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<TRPCContext, "db" | "dbUser">;
@@ -138,53 +138,13 @@ export const vacationReadProcedures = {
input.startDate,
input.endDate,
);
const vacation: Pick<VacationChargeableInput, "startDate" | "endDate" | "isHalfDay"> = {
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({