999626cf70
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>
242 lines
6.8 KiB
TypeScript
242 lines
6.8 KiB
TypeScript
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 },
|
|
});
|
|
}
|