diff --git a/packages/api/src/__tests__/timeline-cost-support.test.ts b/packages/api/src/__tests__/timeline-cost-support.test.ts new file mode 100644 index 0000000..d261028 --- /dev/null +++ b/packages/api/src/__tests__/timeline-cost-support.test.ts @@ -0,0 +1,88 @@ +import { VacationType } from "@capakraken/db"; +import { describe, expect, it } from "vitest"; +import { + buildTimelineAbsenceDays, + loadTimelineCalculationRules, +} from "../router/timeline-cost-support.js"; + +describe("timeline cost support", () => { + it("falls back to default calculation rules when the optional model is unavailable", async () => { + const rules = await loadTimelineCalculationRules({ + vacation: { + findMany: async () => [], + }, + } as never); + + expect(rules.length).toBeGreaterThan(0); + }); + + it("expands approved vacations, sickness, and public holidays into absence days", async () => { + const result = await buildTimelineAbsenceDays({ + vacation: { + findMany: async () => [ + { + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-04T00:00:00.000Z"), + type: VacationType.VACATION, + isHalfDay: false, + }, + { + startDate: new Date("2026-04-05T00:00:00.000Z"), + endDate: new Date("2026-04-05T00:00:00.000Z"), + type: VacationType.SICK, + isHalfDay: true, + }, + { + startDate: new Date("2026-04-06T00:00:00.000Z"), + endDate: new Date("2026-04-06T00:00:00.000Z"), + type: VacationType.PUBLIC_HOLIDAY, + isHalfDay: false, + }, + ], + }, + } as never, "resource_1", new Date("2026-04-01T00:00:00.000Z"), new Date("2026-04-10T00:00:00.000Z")); + + expect(result.absenceDays).toEqual([ + { + date: new Date("2026-04-03T00:00:00.000Z"), + type: "VACATION", + }, + { + date: new Date("2026-04-04T00:00:00.000Z"), + type: "VACATION", + }, + { + date: new Date("2026-04-05T00:00:00.000Z"), + type: "SICK", + isHalfDay: true, + }, + { + date: new Date("2026-04-06T00:00:00.000Z"), + type: "PUBLIC_HOLIDAY", + }, + ]); + expect(result.legacyVacationDates).toEqual([ + new Date("2026-04-03T00:00:00.000Z"), + new Date("2026-04-04T00:00:00.000Z"), + ]); + }); + + it("treats missing optional vacation tables as no absences", async () => { + const result = await buildTimelineAbsenceDays({ + vacation: { + findMany: async () => { + throw { + code: "P2021", + message: "The table `vacations` does not exist.", + meta: { table: "vacations" }, + }; + }, + }, + } as never, "resource_1", new Date("2026-04-01T00:00:00.000Z"), new Date("2026-04-10T00:00:00.000Z")); + + expect(result).toEqual({ + absenceDays: [], + legacyVacationDates: [], + }); + }); +}); diff --git a/packages/api/src/router/timeline-allocation-mutations.ts b/packages/api/src/router/timeline-allocation-mutations.ts index d0684f5..f040962 100644 --- a/packages/api/src/router/timeline-allocation-mutations.ts +++ b/packages/api/src/router/timeline-allocation-mutations.ts @@ -5,10 +5,7 @@ import { loadAllocationEntry, updateAllocationEntry, } from "@capakraken/application"; -import { Prisma, VacationType } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db"; -import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; -import type { AbsenceDay, CalculationRule } from "@capakraken/shared"; import { AllocationStatus, PermissionKey, UpdateAllocationHoursSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -16,119 +13,8 @@ import { emitAllocationCreated, emitAllocationUpdated, } from "../sse/event-bus.js"; -import { logger } from "../lib/logger.js"; import { managerProcedure, requirePermission } from "../trpc.js"; - -function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code !== "P2021") { - return false; - } - const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : ""; - const message = error.message.toLowerCase(); - return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); - } - - if (typeof error !== "object" || error === null || !("code" in error)) { - return false; - } - - const candidate = error as { - code?: unknown; - message?: unknown; - meta?: { table?: unknown }; - }; - const code = typeof candidate.code === "string" ? candidate.code : ""; - if (code !== "P2021") { - return false; - } - const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : ""; - const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : ""; - return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); -} - -export async function loadCalculationRules(db: PrismaClient): Promise { - const calculationRuleModel = (db as PrismaClient & { - calculationRule?: { findMany?: (args: unknown) => Promise }; - }).calculationRule; - - if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") { - return DEFAULT_CALCULATION_RULES; - } - - try { - const rules = await calculationRuleModel.findMany({ - where: { isActive: true }, - orderBy: [{ priority: "desc" }], - }); - if (rules.length > 0) { - return rules as unknown as CalculationRule[]; - } - } catch (error) { - if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) { - logger.error({ err: error }, "Failed to load active calculation rules for timeline"); - throw error; - } - } - return DEFAULT_CALCULATION_RULES; -} - -export async function buildAbsenceDays( - db: PrismaClient, - resourceId: string, - startDate: Date, - endDate: Date, -): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> { - const absenceDays: AbsenceDay[] = []; - const legacyVacationDates: Date[] = []; - - try { - const vacations = await db.vacation.findMany({ - where: { - resourceId, - status: "APPROVED", - startDate: { lte: endDate }, - endDate: { gte: startDate }, - }, - select: { startDate: true, endDate: true, type: true, isHalfDay: true }, - }); - - for (const vacation of vacations) { - const cur = new Date(vacation.startDate); - cur.setHours(0, 0, 0, 0); - const vacationEnd = new Date(vacation.endDate); - vacationEnd.setHours(0, 0, 0, 0); - - const triggerType = vacation.type === VacationType.SICK - ? "SICK" as const - : vacation.type === VacationType.PUBLIC_HOLIDAY - ? "PUBLIC_HOLIDAY" as const - : "VACATION" as const; - - while (cur <= vacationEnd) { - absenceDays.push({ - date: new Date(cur), - type: triggerType, - ...(vacation.isHalfDay ? { isHalfDay: true } : {}), - }); - if (triggerType === "VACATION") { - legacyVacationDates.push(new Date(cur)); - } - cur.setDate(cur.getDate() + 1); - } - } - } catch (error) { - if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) { - logger.error( - { err: error, resourceId, startDate, endDate }, - "Failed to load timeline absence days", - ); - throw error; - } - } - - return { absenceDays, legacyVacationDates }; -} +import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; export const timelineAllocationMutationProcedures = { updateAllocationInline: managerProcedure @@ -174,13 +60,9 @@ export const timelineAllocationMutationProcedures = { const availability = existingResource.availability as unknown as import("@capakraken/shared").WeekdayAvailability; const recurrence = newMeta.recurrence as import("@capakraken/shared").RecurrencePattern | undefined; - - const [absenceData, calculationRules] = await Promise.all([ - buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate), - loadCalculationRules(ctx.db as PrismaClient), - ]); - - newDailyCostCents = calculateAllocation({ + newDailyCostCents = await calculateTimelineAllocationDailyCost({ + db: ctx.db as PrismaClient, + resourceId: resolved.resourceId, lcrCents: existingResource.lcrCents, hoursPerDay: newHoursPerDay, startDate: newStartDate, @@ -188,10 +70,7 @@ export const timelineAllocationMutationProcedures = { availability, includeSaturday, ...(recurrence ? { recurrence } : {}), - vacationDates: absenceData.legacyVacationDates, - absenceDays: absenceData.absenceDays, - calculationRules, - }).dailyCostCents; + }); } const updated = await ctx.db.$transaction(async (tx) => { diff --git a/packages/api/src/router/timeline-cost-support.ts b/packages/api/src/router/timeline-cost-support.ts new file mode 100644 index 0000000..c2db097 --- /dev/null +++ b/packages/api/src/router/timeline-cost-support.ts @@ -0,0 +1,152 @@ +import { Prisma, VacationType, type PrismaClient } from "@capakraken/db"; +import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; +import type { + AbsenceDay, + CalculationRule, + RecurrencePattern, + WeekdayAvailability, +} from "@capakraken/shared"; +import { logger } from "../lib/logger.js"; + +function isMissingOptionalTableError(error: unknown, tableHints: string[]): boolean { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code !== "P2021") { + return false; + } + const table = typeof error.meta?.table === "string" ? error.meta.table.toLowerCase() : ""; + const message = error.message.toLowerCase(); + return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); + } + + if (typeof error !== "object" || error === null || !("code" in error)) { + return false; + } + + const candidate = error as { + code?: unknown; + message?: unknown; + meta?: { table?: unknown }; + }; + const code = typeof candidate.code === "string" ? candidate.code : ""; + if (code !== "P2021") { + return false; + } + const table = typeof candidate.meta?.table === "string" ? candidate.meta.table.toLowerCase() : ""; + const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : ""; + return tableHints.some((hint) => table.includes(hint) || message.includes(hint)); +} + +export async function loadTimelineCalculationRules( + db: PrismaClient, +): Promise { + const calculationRuleModel = (db as PrismaClient & { + calculationRule?: { findMany?: (args: unknown) => Promise }; + }).calculationRule; + + if (!calculationRuleModel || typeof calculationRuleModel.findMany !== "function") { + return DEFAULT_CALCULATION_RULES; + } + + try { + const rules = await calculationRuleModel.findMany({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }); + if (rules.length > 0) { + return rules as unknown as CalculationRule[]; + } + } catch (error) { + if (!isMissingOptionalTableError(error, ["calculationrule", "calculation_rule", "calculation_rules"])) { + logger.error({ err: error }, "Failed to load active calculation rules for timeline"); + throw error; + } + } + return DEFAULT_CALCULATION_RULES; +} + +export async function buildTimelineAbsenceDays( + db: PrismaClient, + resourceId: string, + startDate: Date, + endDate: Date, +): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> { + const absenceDays: AbsenceDay[] = []; + const legacyVacationDates: Date[] = []; + + try { + const vacations = await db.vacation.findMany({ + where: { + resourceId, + status: "APPROVED", + startDate: { lte: endDate }, + endDate: { gte: startDate }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + }); + + for (const vacation of vacations) { + const cur = new Date(vacation.startDate); + cur.setUTCHours(0, 0, 0, 0); + const vacationEnd = new Date(vacation.endDate); + vacationEnd.setUTCHours(0, 0, 0, 0); + + const triggerType = vacation.type === VacationType.SICK + ? "SICK" as const + : vacation.type === VacationType.PUBLIC_HOLIDAY + ? "PUBLIC_HOLIDAY" as const + : "VACATION" as const; + + while (cur <= vacationEnd) { + absenceDays.push({ + date: new Date(cur), + type: triggerType, + ...(vacation.isHalfDay ? { isHalfDay: true } : {}), + }); + if (triggerType === "VACATION") { + legacyVacationDates.push(new Date(cur)); + } + cur.setUTCDate(cur.getUTCDate() + 1); + } + } + } catch (error) { + if (!isMissingOptionalTableError(error, ["vacation", "vacations"])) { + logger.error( + { err: error, resourceId, startDate, endDate }, + "Failed to load timeline absence days", + ); + throw error; + } + } + + return { absenceDays, legacyVacationDates }; +} + +export async function calculateTimelineAllocationDailyCost(input: { + db: PrismaClient; + resourceId: string; + lcrCents: number; + hoursPerDay: number; + startDate: Date; + endDate: Date; + availability: WeekdayAvailability; + includeSaturday?: boolean | undefined; + recurrence?: RecurrencePattern | undefined; +}): Promise { + const [absenceData, calculationRules] = await Promise.all([ + buildTimelineAbsenceDays(input.db, input.resourceId, input.startDate, input.endDate), + loadTimelineCalculationRules(input.db), + ]); + + return calculateAllocation({ + lcrCents: input.lcrCents, + hoursPerDay: input.hoursPerDay, + startDate: input.startDate, + endDate: input.endDate, + availability: input.availability, + includeSaturday: input.includeSaturday ?? false, + ...(input.recurrence ? { recurrence: input.recurrence } : {}), + vacationDates: absenceData.legacyVacationDates, + absenceDays: absenceData.absenceDays, + calculationRules, + }).dailyCostCents; +} diff --git a/packages/api/src/router/timeline-shift-support.ts b/packages/api/src/router/timeline-shift-support.ts index 3b61a34..0c5d28b 100644 --- a/packages/api/src/router/timeline-shift-support.ts +++ b/packages/api/src/router/timeline-shift-support.ts @@ -5,10 +5,10 @@ import { type SplitDemandRequirementRecord, } from "@capakraken/application"; import { Prisma, type PrismaClient } from "@capakraken/db"; -import { calculateAllocation, validateShift } from "@capakraken/engine"; -import type { ShiftValidationResult, WeekdayAvailability } from "@capakraken/shared"; +import { validateShift } from "@capakraken/engine"; +import type { ShiftValidationResult } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; -import { buildAbsenceDays, loadCalculationRules } from "./timeline-allocation-mutations.js"; +import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; import type { TimelineShiftPlan } from "./timeline-shift-planning.js"; export interface TimelineShiftProjectRecord { @@ -123,27 +123,16 @@ async function recalculateShiftedAssignmentDailyCost(input: { const metadata = (input.assignment.metadata as Record | null | undefined) ?? {}; const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; - const [shiftAbsenceData, shiftRules] = await Promise.all([ - buildAbsenceDays( - input.db, - input.assignment.resourceId, - input.newStartDate, - input.newEndDate, - ), - loadCalculationRules(input.db), - ]); - - return calculateAllocation({ + return calculateTimelineAllocationDailyCost({ + db: input.db, + resourceId: input.assignment.resourceId, lcrCents: input.assignment.resource.lcrCents, hoursPerDay: input.assignment.hoursPerDay, startDate: input.newStartDate, endDate: input.newEndDate, - availability: input.assignment.resource.availability as WeekdayAvailability, + availability: input.assignment.resource.availability as import("@capakraken/shared").WeekdayAvailability, includeSaturday, - vacationDates: shiftAbsenceData.legacyVacationDates, - absenceDays: shiftAbsenceData.absenceDays, - calculationRules: shiftRules, - }).dailyCostCents; + }); } export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) {