refactor(api): extract timeline cost load support

This commit is contained in:
2026-03-31 17:53:59 +02:00
parent eef91a1068
commit ff57fc24ce
4 changed files with 278 additions and 198 deletions
@@ -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: [],
});
});
});
@@ -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<typeof import("@capakraken/engine")>();
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" }],
}),
);
});
});
@@ -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<CalculationRule[]> {
const calculationRuleModel = (db as PrismaClient & {
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
}).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 };
}
@@ -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<CalculationRule[]> {
const calculationRuleModel = (db as PrismaClient & {
calculationRule?: { findMany?: (args: unknown) => Promise<unknown[]> };
}).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;