feat(api): explain holiday-aware vacation deductions
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user