Merge branch 'worktree-agent-a8de1898' - Phase 2A entitlement use-cases

This commit is contained in:
2026-04-09 20:16:38 +02:00
6 changed files with 941 additions and 674 deletions
@@ -1,8 +1,15 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import { toIsoDate } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { TRPCError } from "@trpc/server";
import {
getEntitlementBalance as getEntitlementBalanceUseCase,
getEntitlementBalanceDetail as getEntitlementBalanceDetailUseCase,
getEntitlementSync,
getEntitlementYearSummary as getEntitlementYearSummaryUseCase,
getEntitlementYearSummaryDetail as getEntitlementYearSummaryDetailUseCase,
setEntitlement as setEntitlementUseCase,
bulkSetEntitlements as bulkSetEntitlementsUseCase,
type ReadEntitlementBalanceDeps,
} from "@capakraken/application";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
@@ -13,51 +20,6 @@ import {
import type { TRPCContext } from "../trpc.js"; import type { TRPCContext } from "../trpc.js";
import { buildVacationPreview } from "./vacation-read-support.js"; import { buildVacationPreview } from "./vacation-read-support.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
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({ export const EntitlementBalanceInputSchema = z.object({
resourceId: z.string(), resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
@@ -89,96 +51,22 @@ export const EntitlementYearSummaryDetailInputSchema = EntitlementYearSummaryInp
resourceName: z.string().optional(), resourceName: z.string().optional(),
}); });
function mapBalanceDetail(resource: { function buildReadDeps(db: TRPCContext["db"]): ReadEntitlementBalanceDeps {
displayName: string;
eid: string;
}, balance: {
year: number;
entitledDays: number;
carryoverDays: number;
usedDays: number;
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 { return {
resource: resource.displayName, loadResourceHolidayContext: (innerDb, resourceId, periodStart, periodEnd) =>
eid: resource.eid, loadResourceHolidayContext(innerDb as TRPCContext["db"], resourceId, periodStart, periodEnd),
year: balance.year, countCalendarDaysInPeriod,
entitlement: balance.entitledDays, countVacationChargeableDays,
carryOver: balance.carryoverDays, countVacationChargeableDaysFromSnapshot,
taken: balance.usedDays, parseVacationSnapshotDateList,
pending: balance.pendingDays, buildVacationPreview,
remaining: balance.remainingDays,
sickDays: balance.sickDays,
...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}),
...(balance.vacations ? { vacations: balance.vacations } : {}),
}; };
} }
function mapYearSummaryDetail( type EntitlementReadContext = Pick<TRPCContext, "db" | "dbUser">;
year: number, type EntitlementWriteContext = Pick<TRPCContext, "db" | "dbUser">;
summaries: Array<{
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;
pendingDays: number;
remainingDays: number;
}>,
resourceName?: string,
) {
const needle = resourceName?.toLowerCase();
return summaries export async function getEntitlementBalance(
.filter((summary) => {
if (!needle) {
return true;
}
return summary.displayName.toLowerCase().includes(needle)
|| summary.eid.toLowerCase().includes(needle);
})
.slice(0, 50)
.map((summary) => ({
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,
used: summary.usedDays,
pending: summary.pendingDays,
remaining: summary.remainingDays,
}));
}
async function readBalanceSnapshot(
ctx: EntitlementReadContext, ctx: EntitlementReadContext,
input: z.infer<typeof EntitlementBalanceInputSchema>, input: z.infer<typeof EntitlementBalanceInputSchema>,
) { ) {
@@ -198,575 +86,80 @@ async function readBalanceSnapshot(
} }
} }
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); return getEntitlementBalanceUseCase(ctx.db, input, buildReadDeps(ctx.db));
const defaultDays = settings?.vacationDefaultDays ?? 28;
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, vacation) => sum + countCalendarDaysInPeriod(
vacation,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}
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>,
) {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
...RESOURCE_BRIEF_SELECT,
chapter: true,
federalState: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
return Promise.all(
resources.map(async (resource) => {
const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays);
return {
resourceId: resource.id,
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,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
async function getOrCreateEntitlement(
db: TRPCContext["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
async function calculateEntitlementVacationDays(
yearStart: Date,
yearEnd: Date,
vacation: {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
deductedDays: number | null;
holidayCountryCode: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null;
},
getLegacyHolidayContext: () => Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>>,
): Promise<number> {
const persistedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
if (persistedDays !== null) {
return persistedDays;
}
const holidayContext = await getLegacyHolidayContext();
return countVacationChargeableDays({
vacation,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
async function syncEntitlement(
db: TRPCContext["db"],
resourceId: string,
year: number,
defaultDays: number,
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: {
startDate: true,
endDate: true,
status: true,
isHalfDay: true,
deductedDays: true,
holidayCountryCode: true,
holidayFederalState: true,
holidayMetroCityName: true,
holidayCalendarDates: true,
holidayLegacyPublicHolidayDates: true,
},
});
let usedDays = 0;
let pendingDays = 0;
let legacyHolidayContextPromise: Promise<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | null = null;
const getLegacyHolidayContext = async () => {
if (!legacyHolidayContextPromise) {
legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
}
return legacyHolidayContextPromise;
};
for (const vacation of vacations) {
const days = await calculateEntitlementVacationDays(
yearStart,
yearEnd,
vacation,
getLegacyHolidayContext,
);
if (vacation.status === VacationStatus.APPROVED) {
usedDays += days;
} else {
pendingDays += days;
}
}
return db.vacationEntitlement.update({
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}
export async function getEntitlementBalance(
ctx: EntitlementReadContext,
input: z.infer<typeof EntitlementBalanceInputSchema>,
) {
return readBalanceSnapshot(ctx, input);
} }
export async function getEntitlementBalanceDetail( export async function getEntitlementBalanceDetail(
ctx: EntitlementReadContext, ctx: EntitlementReadContext,
input: z.infer<typeof EntitlementBalanceInputSchema>, input: z.infer<typeof EntitlementBalanceInputSchema>,
) { ) {
const [balance, vacations] = await Promise.all([ if (ctx.dbUser) {
readBalanceSnapshot(ctx, input), const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"];
readEntitlementVacationExplainability(ctx, input), if (!allowedRoles.includes(ctx.dbUser.systemRole)) {
]); const resource = await ctx.db.resource.findUnique({
const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId },
where: { id: input.resourceId }, select: { userId: true },
select: { displayName: true, eid: true }, });
}); if (!resource || resource.userId !== ctx.dbUser.id) {
throw new TRPCError({
if (!resource) { code: "FORBIDDEN",
throw new TRPCError({ message: "You can only view your own vacation balance",
code: "NOT_FOUND", });
message: "Resource not found", }
}); }
} }
const approvedVacations = vacations.filter((vacation) => vacation.status === VacationStatus.APPROVED); return getEntitlementBalanceDetailUseCase(ctx.db, input, buildReadDeps(ctx.db));
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( export async function getEntitlement(
ctx: Pick<TRPCContext, "db">, ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementGetInputSchema>, input: z.infer<typeof EntitlementGetInputSchema>,
) { ) {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); return getEntitlementSync(ctx.db, input, buildReadDeps(ctx.db));
const defaultDays = settings?.vacationDefaultDays ?? 28;
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
} }
export async function setEntitlement( export async function setEntitlement(
ctx: EntitlementWriteContext, ctx: EntitlementWriteContext,
input: z.infer<typeof EntitlementSetInputSchema>, input: z.infer<typeof EntitlementSetInputSchema>,
) { ) {
const existing = await ctx.db.vacationEntitlement.findUnique({ const { result, existing } = await setEntitlementUseCase(ctx.db, input);
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
const updated = await ctx.db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
if (existing) {
void createAuditEntry({ void createAuditEntry({
db: ctx.db, db: ctx.db,
entityType: "VacationEntitlement", entityType: "VacationEntitlement",
entityId: updated.id, entityId: result.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`, entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "UPDATE", action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
before: existing as unknown as Record<string, unknown>, before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>, after: result as unknown as Record<string, unknown>,
source: "ui", source: "ui",
summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`, summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`,
}); });
} else {
return updated; void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: result.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: result as unknown as Record<string, unknown>,
source: "ui",
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
});
} }
const created = await ctx.db.vacationEntitlement.create({ return result;
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
void createAuditEntry({
db: ctx.db,
entityType: "VacationEntitlement",
entityId: created.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: created as unknown as Record<string, unknown>,
source: "ui",
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
});
return created;
} }
export async function bulkSetEntitlements( export async function bulkSetEntitlements(
ctx: EntitlementWriteContext, ctx: EntitlementWriteContext,
input: z.infer<typeof EntitlementBulkSetInputSchema>, input: z.infer<typeof EntitlementBulkSetInputSchema>,
) { ) {
const resources = await ctx.db.resource.findMany({ const result = await bulkSetEntitlementsUseCase(ctx.db, input);
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const resource of resources) {
await ctx.db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: resource.id, year: input.year } },
create: {
resourceId: resource.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
void createAuditEntry({ void createAuditEntry({
db: ctx.db, db: ctx.db,
@@ -775,29 +168,24 @@ export async function bulkSetEntitlements(
entityName: `Bulk Entitlement ${input.year}`, entityName: `Bulk Entitlement ${input.year}`,
action: "UPDATE", action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record<string, unknown>, after: { year: input.year, entitledDays: input.entitledDays, resourceCount: result.updated } as unknown as Record<string, unknown>,
source: "ui", source: "ui",
summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`, summary: `Bulk set entitlement to ${input.entitledDays} days for ${result.updated} resources (${input.year})`,
}); });
return { updated }; return result;
} }
export async function getEntitlementYearSummary( export async function getEntitlementYearSummary(
ctx: Pick<TRPCContext, "db">, ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementYearSummaryInputSchema>, input: z.infer<typeof EntitlementYearSummaryInputSchema>,
) { ) {
return readYearSummarySnapshot(ctx, input); return getEntitlementYearSummaryUseCase(ctx.db, input, buildReadDeps(ctx.db));
} }
export async function getEntitlementYearSummaryDetail( export async function getEntitlementYearSummaryDetail(
ctx: Pick<TRPCContext, "db">, ctx: Pick<TRPCContext, "db">,
input: z.infer<typeof EntitlementYearSummaryDetailInputSchema>, input: z.infer<typeof EntitlementYearSummaryDetailInputSchema>,
) { ) {
const summaries = await readYearSummarySnapshot(ctx, { return getEntitlementYearSummaryDetailUseCase(ctx.db, input, buildReadDeps(ctx.db));
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
});
return mapYearSummaryDetail(input.year, summaries, input.resourceName);
} }
+24
View File
@@ -145,6 +145,30 @@ export {
type CancelVacationDeps, type CancelVacationDeps,
} from "./use-cases/vacation/index.js"; } from "./use-cases/vacation/index.js";
export {
syncEntitlement,
getEntitlementBalance,
getEntitlementBalanceDetail,
getEntitlementSync,
getEntitlementYearSummary,
getEntitlementYearSummaryDetail,
setEntitlement,
bulkSetEntitlements,
type EntitlementSnapshot,
type ResourceHolidayContext,
type SyncEntitlementDeps,
type ReadEntitlementBalanceDeps,
type EntitlementBalanceInput,
type EntitlementBalanceResult,
type EntitlementYearSummaryInput,
type EntitlementYearSummaryDetailInput,
type EntitlementYearSummaryRow,
type SetEntitlementInput,
type SetEntitlementReturn,
type BulkSetEntitlementsInput,
type BulkSetEntitlementsResult,
} from "./use-cases/entitlement/index.js";
export { export {
calculateEffectiveAllocationCostCents, calculateEffectiveAllocationCostCents,
calculateEffectiveAllocationHours, calculateEffectiveAllocationHours,
@@ -0,0 +1,29 @@
export {
syncEntitlement,
type EntitlementSnapshot,
type ResourceHolidayContext,
type SyncEntitlementDeps,
} from "./sync-entitlement.js";
export {
getEntitlementBalance,
getEntitlementBalanceDetail,
getEntitlementSync,
getEntitlementYearSummary,
getEntitlementYearSummaryDetail,
type ReadEntitlementBalanceDeps,
type EntitlementBalanceInput,
type EntitlementBalanceResult,
type EntitlementYearSummaryInput,
type EntitlementYearSummaryDetailInput,
type EntitlementYearSummaryRow,
} from "./read-entitlement-balance.js";
export {
setEntitlement,
bulkSetEntitlements,
type SetEntitlementInput,
type SetEntitlementReturn,
type BulkSetEntitlementsInput,
type BulkSetEntitlementsResult,
} from "./set-entitlement.js";
@@ -0,0 +1,496 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@capakraken/db";
import { toIsoDate } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { type EntitlementSnapshot, type ResourceHolidayContext, type SyncEntitlementDeps, syncEntitlement } from "./sync-entitlement.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
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;
};
};
};
type VacationPreviewInput = {
type: VacationType;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
holidayContext: ResourceHolidayContext;
};
type VacationPreviewResult = {
requestedDays: number;
deductedDays: number;
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 type ReadEntitlementBalanceDeps = SyncEntitlementDeps & {
buildVacationPreview: (input: VacationPreviewInput) => VacationPreviewResult;
parseVacationSnapshotDateList: (value: Prisma.JsonValue | null | undefined) => string[];
};
export type EntitlementBalanceInput = {
resourceId: string;
year: number;
};
export type EntitlementYearSummaryInput = {
year: number;
chapter?: string | undefined;
};
export type EntitlementYearSummaryDetailInput = EntitlementYearSummaryInput & {
resourceName?: string | undefined;
};
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 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 hasPersistedHolidaySnapshot(vacation: {
deductedDays: number | null;
holidayCountryCode: string | null;
holidayCountryName: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: 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}`,
});
}
function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] {
return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort();
}
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 mapBalanceDetail(resource: {
displayName: string;
eid: string;
}, balance: {
year: number;
entitledDays: number;
carryoverDays: number;
usedDays: number;
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,
eid: resource.eid,
year: balance.year,
entitlement: balance.entitledDays,
carryOver: balance.carryoverDays,
taken: balance.usedDays,
pending: balance.pendingDays,
remaining: balance.remainingDays,
sickDays: balance.sickDays,
...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}),
...(balance.vacations ? { vacations: balance.vacations } : {}),
};
}
async function readBalanceSnapshot(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const entitlement = await syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
const sickVacationsResult = await db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, vacation) => sum + deps.countCalendarDaysInPeriod(
vacation,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}
async function readEntitlementVacationExplainability(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
): 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 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<ResourceHolidayContext> | null = null;
const getVacationHolidayContext = async () => {
if (!vacationHolidayContextPromise) {
vacationHolidayContextPromise = deps.loadResourceHolidayContext(
db,
input.resourceId,
period.startDate,
period.endDate,
);
}
return vacationHolidayContextPromise;
};
const fallbackHolidayContext = await getVacationHolidayContext();
const preview = deps.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(
deps.parseVacationSnapshotDateList(vacation.holidayCalendarDates),
period.startDate,
period.endDate,
),
publicHolidayStrings: filterIsoDatesToRange(
deps.parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates),
period.startDate,
period.endDate,
),
}
: fallbackHolidayContext,
});
const persistedDeductedDays = deps.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(
db: DbClient,
input: EntitlementYearSummaryInput,
deps: ReadEntitlementBalanceDeps,
) {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
chapter: true,
federalState: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
return Promise.all(
resources.map(async (resource) => {
const entitlement = await syncEntitlement(db, resource.id, input.year, defaultDays, deps);
return {
resourceId: resource.id,
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,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
}
export type EntitlementBalanceResult = Awaited<ReturnType<typeof readBalanceSnapshot>>;
export type EntitlementYearSummaryRow = Awaited<ReturnType<typeof readYearSummarySnapshot>>[number];
export async function getEntitlementBalance(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
return readBalanceSnapshot(db, input, deps);
}
export async function getEntitlementBalanceDetail(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
const [balance, vacations] = await Promise.all([
readBalanceSnapshot(db, input, deps),
readEntitlementVacationExplainability(db, input, deps),
]);
const resource = await db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
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 getEntitlementSync(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
): Promise<EntitlementSnapshot> {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
}
export async function getEntitlementYearSummary(
db: DbClient,
input: EntitlementYearSummaryInput,
deps: ReadEntitlementBalanceDeps,
) {
return readYearSummarySnapshot(db, input, deps);
}
export async function getEntitlementYearSummaryDetail(
db: DbClient,
input: EntitlementYearSummaryDetailInput,
deps: ReadEntitlementBalanceDeps,
) {
const summaries = await readYearSummarySnapshot(db, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
}, deps);
const needle = input.resourceName?.toLowerCase();
return summaries
.filter((summary) => {
if (!needle) {
return true;
}
return summary.displayName.toLowerCase().includes(needle)
|| summary.eid.toLowerCase().includes(needle);
})
.slice(0, 50)
.map((summary) => ({
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: input.year,
entitled: summary.entitledDays,
carryover: summary.carryoverDays,
used: summary.usedDays,
pending: summary.pendingDays,
remaining: summary.remainingDays,
}));
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "@capakraken/db";
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
export type SetEntitlementInput = {
resourceId: string;
year: number;
entitledDays: number;
};
export type BulkSetEntitlementsInput = {
year: number;
entitledDays: number;
resourceIds?: string[] | undefined;
};
export type SetEntitlementResult = Awaited<ReturnType<PrismaClient["vacationEntitlement"]["update"]>>;
export type CreateEntitlementResult = Awaited<ReturnType<PrismaClient["vacationEntitlement"]["create"]>>;
export type SetEntitlementReturn = {
result: SetEntitlementResult | CreateEntitlementResult;
existing: SetEntitlementResult | null;
};
export async function setEntitlement(
db: DbClient,
input: SetEntitlementInput,
): Promise<SetEntitlementReturn> {
const existing = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
const updated = await db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
return { result: updated, existing };
}
const created = await db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
return { result: created, existing: null };
}
export type BulkSetEntitlementsResult = {
updated: number;
};
export async function bulkSetEntitlements(
db: DbClient,
input: BulkSetEntitlementsInput,
): Promise<BulkSetEntitlementsResult> {
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const resource of resources) {
await db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: resource.id, year: input.year } },
create: {
resourceId: resource.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
return { updated };
}
@@ -0,0 +1,241 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
export type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
type VacationSnapshotCarrier = {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
deductedDays: number | null;
holidayCountryCode: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: Prisma.JsonValue | null;
};
export type ResourceHolidayContext = {
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
};
export type SyncEntitlementDeps = {
loadResourceHolidayContext: (
db: DbClient,
resourceId: string,
periodStart: Date,
periodEnd: Date,
) => Promise<ResourceHolidayContext>;
countCalendarDaysInPeriod: (
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean },
periodStart?: Date,
periodEnd?: Date,
) => number;
countVacationChargeableDays: (args: {
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean };
periodStart?: Date;
periodEnd?: Date;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
}) => number;
countVacationChargeableDaysFromSnapshot: (
vacation: VacationSnapshotCarrier,
periodStart?: Date,
periodEnd?: Date,
) => number | null;
};
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
async function getOrCreateEntitlement(
db: DbClient,
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
async function calculateEntitlementVacationDays(
yearStart: Date,
yearEnd: Date,
vacation: VacationSnapshotCarrier,
getLegacyHolidayContext: () => Promise<ResourceHolidayContext>,
deps: SyncEntitlementDeps,
): Promise<number> {
const persistedDays = deps.countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
if (persistedDays !== null) {
return persistedDays;
}
const holidayContext = await getLegacyHolidayContext();
return deps.countVacationChargeableDays({
vacation,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode ?? null,
federalState: holidayContext.federalState ?? null,
metroCityName: holidayContext.metroCityName ?? null,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
export async function syncEntitlement(
db: DbClient,
resourceId: string,
year: number,
defaultDays: number,
deps: SyncEntitlementDeps,
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
deps,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: {
startDate: true,
endDate: true,
status: true,
isHalfDay: true,
deductedDays: true,
holidayCountryCode: true,
holidayFederalState: true,
holidayMetroCityName: true,
holidayCalendarDates: true,
holidayLegacyPublicHolidayDates: true,
},
});
let usedDays = 0;
let pendingDays = 0;
let legacyHolidayContextPromise: Promise<ResourceHolidayContext> | null = null;
const getLegacyHolidayContext = async () => {
if (!legacyHolidayContextPromise) {
legacyHolidayContextPromise = deps.loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
}
return legacyHolidayContextPromise;
};
for (const vacation of vacations) {
const days = await calculateEntitlementVacationDays(
yearStart,
yearEnd,
vacation,
getLegacyHolidayContext,
deps,
);
if (vacation.status === VacationStatus.APPROVED) {
usedDays += days;
} else {
pendingDays += days;
}
}
return db.vacationEntitlement.update({
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}