diff --git a/packages/api/src/router/entitlement-procedure-support.ts b/packages/api/src/router/entitlement-procedure-support.ts index f35bf31..54d2474 100644 --- a/packages/api/src/router/entitlement-procedure-support.ts +++ b/packages/api/src/router/entitlement-procedure-support.ts @@ -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 { 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 { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; @@ -13,51 +20,6 @@ import { import type { TRPCContext } from "../trpc.js"; import { buildVacationPreview } from "./vacation-read-support.js"; -/** Types that consume from annual leave balance */ -const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; - -type EntitlementSnapshot = { - id: string; - entitledDays: number; - carryoverDays: number; - usedDays: number; - pendingDays: number; -}; - -type EntitlementReadContext = Pick; -type EntitlementWriteContext = Pick; -type EntitlementVacationStatus = "APPROVED" | "PENDING"; - -type EntitlementVacationExplainability = { - type: VacationType; - status: EntitlementVacationStatus; - startDate: string; - endDate: string; - isHalfDay: boolean; - requestedDays: number; - deductedDays: number; - holidayCountryCode: string | null; - holidayCountryName: string | null; - holidayFederalState: string | null; - holidayMetroCityName: string | null; - holidayCalendarDates: string[]; - holidayLegacyPublicHolidayDates: string[]; - holidayDetails: Array<{ - date: string; - source: "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY"; - }>; - holidayContext: { - countryCode: string | null; - countryName: string | null; - federalState: string | null; - metroCityName: string | null; - sources: { - hasCalendarHolidays: boolean; - hasLegacyPublicHolidayEntries: boolean; - }; - }; -}; - export const EntitlementBalanceInputSchema = z.object({ resourceId: z.string(), year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), @@ -89,96 +51,22 @@ export const EntitlementYearSummaryDetailInputSchema = EntitlementYearSummaryInp resourceName: z.string().optional(), }); -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[]; -}) { +function buildReadDeps(db: TRPCContext["db"]): ReadEntitlementBalanceDeps { 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 } : {}), + loadResourceHolidayContext: (innerDb, resourceId, periodStart, periodEnd) => + loadResourceHolidayContext(innerDb as TRPCContext["db"], resourceId, periodStart, periodEnd), + countCalendarDaysInPeriod, + countVacationChargeableDays, + countVacationChargeableDaysFromSnapshot, + parseVacationSnapshotDateList, + buildVacationPreview, }; } -function mapYearSummaryDetail( - year: number, - 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(); +type EntitlementReadContext = Pick; +type EntitlementWriteContext = Pick; - 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, - entitled: summary.entitledDays, - carryover: summary.carryoverDays, - used: summary.usedDays, - pending: summary.pendingDays, - remaining: summary.remainingDays, - })); -} - -async function readBalanceSnapshot( +export async function getEntitlementBalance( ctx: EntitlementReadContext, input: z.infer, ) { @@ -198,575 +86,80 @@ async function readBalanceSnapshot( } } - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - 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, -): Promise { - 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>> | 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, - input: z.infer, -) { - 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>>, -): Promise { - 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 = new Set(), -): Promise { - 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>> | 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, -) { - return readBalanceSnapshot(ctx, input); + return getEntitlementBalanceUseCase(ctx.db, input, buildReadDeps(ctx.db)); } export async function getEntitlementBalanceDetail( ctx: EntitlementReadContext, input: z.infer, ) { - const [balance, vacations] = await Promise.all([ - readBalanceSnapshot(ctx, input), - readEntitlementVacationExplainability(ctx, input), - ]); - const resource = await ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { displayName: true, eid: true }, - }); - - if (!resource) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Resource not found", - }); + if (ctx.dbUser) { + const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"]; + if (!allowedRoles.includes(ctx.dbUser.systemRole)) { + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { userId: true }, + }); + if (!resource || resource.userId !== ctx.dbUser.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view your own vacation balance", + }); + } + } } - const approvedVacations = vacations.filter((vacation) => vacation.status === VacationStatus.APPROVED); - const pendingVacations = vacations.filter((vacation) => vacation.status === VacationStatus.PENDING); - - return mapBalanceDetail(resource, { - ...balance, - deductionSummary: { - formula: "remaining = entitlement - taken - pending", - approvedVacationCount: approvedVacations.length, - pendingVacationCount: pendingVacations.length, - approvedRequestedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), - pendingRequestedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), - approvedDeductedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), - pendingDeductedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), - excludedHolidayDates: buildEntitlementHolidayDateUnion(vacations), - holidayBasisVariants: [...new Set(vacations.map(formatEntitlementHolidayBasis).filter((value) => value.length > 0))], - sources: { - hasCalendarHolidays: vacations.some((vacation) => vacation.holidayContext.sources.hasCalendarHolidays), - hasLegacyPublicHolidayEntries: vacations.some((vacation) => vacation.holidayContext.sources.hasLegacyPublicHolidayEntries), - }, - }, - vacations, - }); + return getEntitlementBalanceDetailUseCase(ctx.db, input, buildReadDeps(ctx.db)); } export async function getEntitlement( ctx: Pick, input: z.infer, ) { - const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); - const defaultDays = settings?.vacationDefaultDays ?? 28; - return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); + return getEntitlementSync(ctx.db, input, buildReadDeps(ctx.db)); } export async function setEntitlement( ctx: EntitlementWriteContext, input: z.infer, ) { - const existing = await ctx.db.vacationEntitlement.findUnique({ - 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 }, - }); + const { result, existing } = await setEntitlementUseCase(ctx.db, input); + if (existing) { void createAuditEntry({ db: ctx.db, entityType: "VacationEntitlement", - entityId: updated.id, + entityId: result.id, entityName: `Entitlement ${input.resourceId} / ${input.year}`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: existing as unknown as Record, - after: updated as unknown as Record, + after: result as unknown as Record, source: "ui", summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`, }); - - return updated; + } else { + 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, + source: "ui", + summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, + }); } - 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({ - 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, - source: "ui", - summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, - }); - - return created; + return result; } export async function bulkSetEntitlements( ctx: EntitlementWriteContext, input: z.infer, ) { - const resources = await ctx.db.resource.findMany({ - 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++; - } + const result = await bulkSetEntitlementsUseCase(ctx.db, input); void createAuditEntry({ db: ctx.db, @@ -775,29 +168,24 @@ export async function bulkSetEntitlements( entityName: `Bulk Entitlement ${input.year}`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record, + after: { year: input.year, entitledDays: input.entitledDays, resourceCount: result.updated } as unknown as Record, 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( ctx: Pick, input: z.infer, ) { - return readYearSummarySnapshot(ctx, input); + return getEntitlementYearSummaryUseCase(ctx.db, input, buildReadDeps(ctx.db)); } export async function getEntitlementYearSummaryDetail( ctx: Pick, input: z.infer, ) { - const summaries = await readYearSummarySnapshot(ctx, { - year: input.year, - ...(input.chapter ? { chapter: input.chapter } : {}), - }); - - return mapYearSummaryDetail(input.year, summaries, input.resourceName); + return getEntitlementYearSummaryDetailUseCase(ctx.db, input, buildReadDeps(ctx.db)); } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index feb07a8..d2e4aa0 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -145,6 +145,30 @@ export { 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 { calculateEffectiveAllocationCostCents, calculateEffectiveAllocationHours, diff --git a/packages/application/src/use-cases/entitlement/index.ts b/packages/application/src/use-cases/entitlement/index.ts new file mode 100644 index 0000000..1c54b68 --- /dev/null +++ b/packages/application/src/use-cases/entitlement/index.ts @@ -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"; diff --git a/packages/application/src/use-cases/entitlement/read-entitlement-balance.ts b/packages/application/src/use-cases/entitlement/read-entitlement-balance.ts new file mode 100644 index 0000000..9893a19 --- /dev/null +++ b/packages/application/src/use-cases/entitlement/read-entitlement-balance.ts @@ -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; + +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 { + 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 | 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>; +export type EntitlementYearSummaryRow = Awaited>[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 { + 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, + })); +} diff --git a/packages/application/src/use-cases/entitlement/set-entitlement.ts b/packages/application/src/use-cases/entitlement/set-entitlement.ts new file mode 100644 index 0000000..3963e59 --- /dev/null +++ b/packages/application/src/use-cases/entitlement/set-entitlement.ts @@ -0,0 +1,89 @@ +import type { PrismaClient } from "@capakraken/db"; + +type DbClient = Pick; + +export type SetEntitlementInput = { + resourceId: string; + year: number; + entitledDays: number; +}; + +export type BulkSetEntitlementsInput = { + year: number; + entitledDays: number; + resourceIds?: string[] | undefined; +}; + +export type SetEntitlementResult = Awaited>; +export type CreateEntitlementResult = Awaited>; + +export type SetEntitlementReturn = { + result: SetEntitlementResult | CreateEntitlementResult; + existing: SetEntitlementResult | null; +}; + +export async function setEntitlement( + db: DbClient, + input: SetEntitlementInput, +): Promise { + 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 { + 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 }; +} diff --git a/packages/application/src/use-cases/entitlement/sync-entitlement.ts b/packages/application/src/use-cases/entitlement/sync-entitlement.ts new file mode 100644 index 0000000..29491f6 --- /dev/null +++ b/packages/application/src/use-cases/entitlement/sync-entitlement.ts @@ -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; + +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; + 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, + deps: SyncEntitlementDeps, +): Promise { + 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 = new Set(), +): Promise { + 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 | 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 }, + }); +}