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:
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user