diff --git a/packages/api/src/__tests__/timeline-cost-load-support.test.ts b/packages/api/src/__tests__/timeline-cost-load-support.test.ts new file mode 100644 index 0000000..2105eef --- /dev/null +++ b/packages/api/src/__tests__/timeline-cost-load-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-load-support.js"; + +describe("timeline cost load 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/__tests__/timeline-cost-support.test.ts b/packages/api/src/__tests__/timeline-cost-support.test.ts index d261028..2ac2b2c 100644 --- a/packages/api/src/__tests__/timeline-cost-support.test.ts +++ b/packages/api/src/__tests__/timeline-cost-support.test.ts @@ -1,88 +1,75 @@ -import { VacationType } from "@capakraken/db"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +const { calculateAllocationMock } = vi.hoisted(() => ({ + calculateAllocationMock: vi.fn(), +})); + +vi.mock("@capakraken/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + calculateAllocation: calculateAllocationMock, + }; +}); + +vi.mock("../router/timeline-cost-load-support.js", () => ({ + buildTimelineAbsenceDays: vi.fn(), + loadTimelineCalculationRules: vi.fn(), +})); + import { buildTimelineAbsenceDays, loadTimelineCalculationRules, -} from "../router/timeline-cost-support.js"; +} from "../router/timeline-cost-load-support.js"; +import { calculateTimelineAllocationDailyCost } from "../router/timeline-cost-support.js"; + +const buildTimelineAbsenceDaysMock = vi.mocked(buildTimelineAbsenceDays); +const loadTimelineCalculationRulesMock = vi.mocked(loadTimelineCalculationRules); 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: [], + it("builds timeline allocation daily cost from loaded absences and rules", async () => { + buildTimelineAbsenceDaysMock.mockResolvedValueOnce({ + absenceDays: [{ date: new Date("2026-04-03T00:00:00.000Z"), type: "VACATION" }], + legacyVacationDates: [new Date("2026-04-03T00:00:00.000Z")], }); + loadTimelineCalculationRulesMock.mockResolvedValueOnce([{ id: "rule_1" }] as never); + calculateAllocationMock.mockReturnValueOnce({ dailyCostCents: 54_321 }); + + await expect( + calculateTimelineAllocationDailyCost({ + db: {} as never, + resourceId: "resource_1", + lcrCents: 5_000, + hoursPerDay: 8, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + } as never, + includeSaturday: true, + }), + ).resolves.toBe(54_321); + + expect(buildTimelineAbsenceDaysMock).toHaveBeenCalledWith( + {}, + "resource_1", + new Date("2026-04-01T00:00:00.000Z"), + new Date("2026-04-10T00:00:00.000Z"), + ); + expect(loadTimelineCalculationRulesMock).toHaveBeenCalledWith({}); + expect(calculateAllocationMock).toHaveBeenCalledWith( + expect.objectContaining({ + lcrCents: 5_000, + hoursPerDay: 8, + includeSaturday: true, + vacationDates: [new Date("2026-04-03T00:00:00.000Z")], + absenceDays: [{ date: new Date("2026-04-03T00:00:00.000Z"), type: "VACATION" }], + calculationRules: [{ id: "rule_1" }], + }), + ); }); }); diff --git a/packages/api/src/router/timeline-cost-load-support.ts b/packages/api/src/router/timeline-cost-load-support.ts new file mode 100644 index 0000000..a1a8b3e --- /dev/null +++ b/packages/api/src/router/timeline-cost-load-support.ts @@ -0,0 +1,117 @@ +import { Prisma, VacationType, type PrismaClient } from "@capakraken/db"; +import { DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; +import type { AbsenceDay, CalculationRule } 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 }; +} diff --git a/packages/api/src/router/timeline-cost-support.ts b/packages/api/src/router/timeline-cost-support.ts index c2db097..ba25a49 100644 --- a/packages/api/src/router/timeline-cost-support.ts +++ b/packages/api/src/router/timeline-cost-support.ts @@ -1,125 +1,13 @@ -import { Prisma, VacationType, type PrismaClient } from "@capakraken/db"; -import { calculateAllocation, DEFAULT_CALCULATION_RULES } from "@capakraken/engine"; +import type { PrismaClient } from "@capakraken/db"; +import { calculateAllocation } 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 }; -} +import { + buildTimelineAbsenceDays, + loadTimelineCalculationRules, +} from "./timeline-cost-load-support.js"; export async function calculateTimelineAllocationDailyCost(input: { db: PrismaClient;