diff --git a/packages/api/src/router/allocation-conflict-procedures.ts b/packages/api/src/router/allocation-conflict-procedures.ts new file mode 100644 index 0000000..2829e77 --- /dev/null +++ b/packages/api/src/router/allocation-conflict-procedures.ts @@ -0,0 +1,125 @@ +import { validateAvailability } from "@capakraken/engine"; +import { + type AllocationConflictCheckResult, + type WeekdayAvailability, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { managerProcedure } from "../trpc.js"; +import { toIsoDate } from "./allocation-shared.js"; + +const CheckConflictsInputSchema = z.object({ + resourceId: z.string(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0).max(24), + /** Pass the current assignment id when editing so it is excluded from existing load. */ + excludeAssignmentId: z.string().optional(), +}); + +export const allocationConflictProcedures = { + /** + * Pre-flight conflict check for the allocation form. + * Returns overbooking details (per-day breakdown) and vacation overlaps. + * Read-only — no mutations. + */ + checkConflicts: managerProcedure + .input(CheckConflictsInputSchema) + .query(async ({ ctx, input }): Promise => { + const resource = await ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + availability: true, + fte: true, + country: { select: { dailyWorkingHours: true } }, + }, + }); + + if (!resource) { + throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); + } + + const fallbackDailyHours = + (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1); + const availability = (resource.availability as WeekdayAvailability | null) ?? { + monday: fallbackDailyHours, + tuesday: fallbackDailyHours, + wednesday: fallbackDailyHours, + thursday: fallbackDailyHours, + friday: fallbackDailyHours, + saturday: 0, + sunday: 0, + }; + + // Load existing active assignments for the resource in the period + const existingAssignments = await ctx.db.assignment.findMany({ + where: { + resourceId: input.resourceId, + status: { not: "CANCELLED" }, + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + ...(input.excludeAssignmentId ? { id: { not: input.excludeAssignmentId } } : {}), + }, + select: { startDate: true, endDate: true, hoursPerDay: true, status: true }, + }); + + // Load approved vacations that overlap the requested period + const vacations = await ctx.db.vacation.findMany({ + where: { + resourceId: input.resourceId, + status: "APPROVED", + startDate: { lte: input.endDate }, + endDate: { gte: input.startDate }, + }, + select: { startDate: true, endDate: true, type: true, isHalfDay: true }, + orderBy: { startDate: "asc" }, + }); + + // Run pure availability check (no DB access). + // Cast status to the shared enum — Prisma and shared enums are string-compatible. + const availabilityResult = validateAvailability( + input.startDate, + input.endDate, + input.hoursPerDay, + availability, + existingAssignments as { startDate: Date; endDate: Date; hoursPerDay: number; status: import("@capakraken/shared").AllocationStatus }[], + ); + + // Compute max overbook percentage for the worst day + let maxOverbookPercent = 0; + for (const conflict of availabilityResult.conflicts) { + const totalBooked = conflict.existingHours + conflict.requestedHours; + const overbookPct = + conflict.availableHours > 0 + ? ((totalBooked / conflict.availableHours) - 1) * 100 + : 100; + if (overbookPct > maxOverbookPercent) maxOverbookPercent = overbookPct; + } + + const isOverbooking = availabilityResult.totalConflictDays > 0; + + return { + isOverbooking, + overbooking: isOverbooking + ? { + conflictDays: availabilityResult.conflicts.map((c) => ({ + date: toIsoDate(c.date), + availableHours: c.availableHours, + existingHours: c.existingHours, + requestedHours: c.requestedHours, + overageHours: c.overageHours, + })), + totalConflictDays: availabilityResult.totalConflictDays, + maxOverbookPercent: Math.round(maxOverbookPercent), + } + : null, + vacationOverlap: vacations.map((v) => ({ + startDate: toIsoDate(v.startDate), + endDate: toIsoDate(v.endDate), + type: v.type, + isHalfDay: v.isHalfDay, + })), + hasVacationOverlap: vacations.length > 0, + }; + }), +}; diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index d3f361b..cd24a7b 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -1,4 +1,5 @@ import { allocationAssignmentProcedures } from "./allocation-assignment-procedures.js"; +import { allocationConflictProcedures } from "./allocation-conflict-procedures.js"; import { allocationDemandProcedures } from "./allocation-demand-procedures.js"; import { allocationReadProcedures } from "./allocation-read-procedures.js"; import { createTRPCRouter } from "../trpc.js"; @@ -7,4 +8,5 @@ export const allocationRouter = createTRPCRouter({ ...allocationReadProcedures, ...allocationDemandProcedures, ...allocationAssignmentProcedures, + ...allocationConflictProcedures, }); diff --git a/packages/application/src/use-cases/allocation/create-assignment.ts b/packages/application/src/use-cases/allocation/create-assignment.ts index b42f377..0af0ed9 100644 --- a/packages/application/src/use-cases/allocation/create-assignment.ts +++ b/packages/application/src/use-cases/allocation/create-assignment.ts @@ -143,10 +143,10 @@ export async function createAssignment( availabilityWindows, ); - if (!availabilityResult.valid && availabilityResult.totalConflictDays > 5) { + if (!availabilityResult.valid && !input.allowOverbooking) { throw new TRPCError({ - code: "BAD_REQUEST", - message: `Resource has availability conflicts on ${availabilityResult.totalConflictDays} days`, + code: "CONFLICT", + message: `Resource is overbooked on ${availabilityResult.totalConflictDays} day(s). Pass allowOverbooking: true to proceed.`, }); } @@ -179,6 +179,7 @@ export async function createAssignment( dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents, status: input.status, metadata: input.metadata as unknown as Prisma.InputJsonValue, + overbookingAcknowledged: input.allowOverbooking === true && !availabilityResult.valid, }, include: ASSIGNMENT_RELATIONS_INCLUDE, }); @@ -194,3 +195,55 @@ export async function createAssignment( return assignment; } + +/** + * Creates an assignment record without running availability or duplicate checks. + * Use ONLY for fragment operations (splitting/carving an existing allocation) + * where the resource's total load does not change. + */ +export async function createAssignmentFragment( + db: DbClient, + input: { + demandRequirementId?: string | null | undefined; + resourceId: string; + projectId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + percentage: number; + role?: string | null | undefined; + roleId?: string | null | undefined; + dailyCostCents: number; + status: string; + metadata: unknown; + }, +): Promise { + const assignment = await db.assignment.create({ + data: { + demandRequirementId: input.demandRequirementId ?? null, + resourceId: input.resourceId, + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + hoursPerDay: input.hoursPerDay, + percentage: input.percentage, + role: input.role ?? null, + roleId: input.roleId ?? null, + dailyCostCents: input.dailyCostCents, + status: input.status as import("@capakraken/db").AllocationStatus, + metadata: input.metadata as import("@capakraken/db").Prisma.InputJsonValue, + }, + include: ASSIGNMENT_RELATIONS_INCLUDE, + }); + + await db.auditLog.create({ + data: { + entityType: "Assignment", + entityId: assignment.id, + action: "CREATE", + changes: { after: assignment } as unknown as import("@capakraken/db").Prisma.InputJsonValue, + }, + }); + + return assignment; +} diff --git a/packages/shared/src/schemas/allocation.schema.ts b/packages/shared/src/schemas/allocation.schema.ts index 140ba5b..ea649f9 100644 --- a/packages/shared/src/schemas/allocation.schema.ts +++ b/packages/shared/src/schemas/allocation.schema.ts @@ -44,7 +44,7 @@ export const CreateAssignmentBaseSchema = z.object({ status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED), metadata: z.record(z.string(), z.unknown()).default({}), /** When true the caller acknowledges the resource will be overbooked. */ - allowOverbooking: z.boolean().optional().default(false), + allowOverbooking: z.boolean().optional(), }); /**