feat(application): extract entitlement use-cases from API router layer

Move core entitlement business logic (syncEntitlement, balance reading,
year summary, set/bulk-set) into packages/application/src/use-cases/entitlement/
using the deps-injection pattern. Audit logging stays in the router support
file; authorization check for getBalance/getBalanceDetail stays in the router
layer. The router support file becomes a thin wiring adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:14:35 +02:00
parent d3bfa8ca98
commit 999626cf70
6 changed files with 966 additions and 677 deletions
@@ -1,7 +1,15 @@
import { VacationType, VacationStatus } from "@capakraken/db";
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";
@@ -12,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()),
@@ -88,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>,
) { ) {
@@ -197,579 +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 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>,
) {
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: { displayName: true, eid: true }, select: { userId: true },
}); });
if (!resource || resource.userId !== ctx.dbUser.id) {
if (!resource) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "FORBIDDEN",
message: "Resource not found", message: "You can only view your own vacation balance",
}); });
} }
}
}
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;
}
const created = await ctx.db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
void createAuditEntry({ void createAuditEntry({
db: ctx.db, db: ctx.db,
entityType: "VacationEntitlement", entityType: "VacationEntitlement",
entityId: created.id, entityId: result.id,
entityName: `Entitlement ${input.resourceId} / ${input.year}`, entityName: `Entitlement ${input.resourceId} / ${input.year}`,
action: "CREATE", action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
after: created as unknown as Record<string, unknown>, after: result as unknown as Record<string, unknown>,
source: "ui", source: "ui",
summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, summary: `Set entitlement to ${input.entitledDays} days (${input.year})`,
}); });
}
return created; return result;
} }
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,
@@ -778,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);
} }
+49
View File
@@ -6,6 +6,7 @@ export { updateDemandRequirement } from "./use-cases/allocation/update-demand-re
export { export {
createAssignment, createAssignment,
createAssignmentFragment,
type AssignmentWithRelations, type AssignmentWithRelations,
} from "./use-cases/allocation/create-assignment.js"; } from "./use-cases/allocation/create-assignment.js";
export { updateAssignment } from "./use-cases/allocation/update-assignment.js"; export { updateAssignment } from "./use-cases/allocation/update-assignment.js";
@@ -120,6 +121,54 @@ export {
type RecomputeResourceValueScoresInput, type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js"; } from "./use-cases/resource/index.js";
export {
approveVacation,
batchApproveVacations,
rejectVacation,
batchRejectVacations,
cancelVacation,
type ApproveVacationInput,
type ApproveVacationResult,
type ApproveVacationDeps,
type BatchApproveVacationInput,
type BatchApproveVacationResult,
type BatchApproveVacationDeps,
type VacationChargeableInput,
type RejectVacationInput,
type RejectVacationResult,
type RejectVacationDeps,
type BatchRejectVacationInput,
type BatchRejectVacationResult,
type BatchRejectVacationDeps,
type CancelVacationInput,
type CancelVacationResult,
type CancelVacationDeps,
} 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 },
});
}