refactor(api): extract timeline cost support

This commit is contained in:
2026-03-31 15:04:07 +02:00
parent e1de9a3a98
commit b05758db69
4 changed files with 253 additions and 145 deletions
@@ -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<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 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) => {