import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { isEffectiveOn, type GetAssignmentDep } from "../lib/_workforceDeps"; import { MissingRequiredFieldError, BulkInputEmptyError, BulkInputTooLargeError, ShiftNotFoundError, ShiftCancelledError, AssignmentNotEffectiveError, ShiftPlacementAlreadyExistsError, } from "../lib/errors.generated"; import { MAX_BULK_INPUT_SIZE } from "./createShifts"; export interface CreateShiftPlacementsEntryInput { shiftId: string; assignmentId: string; } export interface CreateShiftPlacementsInput { assignments: CreateShiftPlacementsEntryInput[]; } /** * Function: createShiftPlacements * Description: Bulk-places many Assignments onto many Shifts in one all-or-nothing transaction, * stamping every placement with provenance = GENERATED. It is the counterpart to createShifts, * used by generation logic (e.g. a periodic-rotation shiftSchedule generator) that must place hundreds * or thousands of Assignments without issuing one createShiftPlacement call per placement. */ export async function run>( db: Transaction, // Custom fields are per entry: the batch writes one ShiftPlacement row per entry. input: Omit & { assignments: (CreateShiftPlacementsEntryInput & CF)[]; }, ctx: CommandContext, deps: GetAssignmentDep, ) { if (!input.assignments) { return err(new MissingRequiredFieldError("assignments")); } if (input.assignments.length === 0) { return err(new BulkInputEmptyError("createShiftPlacements")); } if (input.assignments.length > MAX_BULK_INPUT_SIZE) { return err(new BulkInputTooLargeError(`createShiftPlacements:${input.assignments.length}`)); } for (const [index, entry] of input.assignments.entries()) { if (!entry.shiftId || !entry.assignmentId) { return err(new MissingRequiredFieldError(`assignments[${index}].shiftId/assignmentId`)); } } // Batch-load every referenced Shift once, rather than once per entry. const shiftIds = [...new Set(input.assignments.map((a) => a.shiftId))]; const shifts = await db .selectFrom("Shift") .selectAll() .where("id", "in", shiftIds) .forUpdate() .execute(); const shiftById = new Map(shifts.map((s) => [s.id, s])); for (const [index, entry] of input.assignments.entries()) { const shift = shiftById.get(entry.shiftId); if (!shift) { return err(new ShiftNotFoundError(`assignments[${index}].${entry.shiftId}`)); } if (shift.cancelledAt !== null) { return err(new ShiftCancelledError(`assignments[${index}].${entry.shiftId}`)); } } // Each referenced Assignment is read once, not once per entry, and checked against the date // of every slot it is being placed on. A batch is all-or-nothing, so one ineffective pairing // rejects the lot rather than leaving a partially valid shiftSchedule behind. const assignmentIds = [...new Set(input.assignments.map((a) => a.assignmentId))]; const assignmentById = new Map(); for (const assignmentId of assignmentIds) { const result = await deps.getAssignment(db, { id: assignmentId }, ctx); if (!result.ok) { return err(new AssignmentNotEffectiveError(assignmentId)); } assignmentById.set(assignmentId, result.value.assignment); } for (const [index, entry] of input.assignments.entries()) { // Both maps were built from these same entries above, so a miss is unreachable; the guards // narrow the lookups rather than signalling a distinct failure. const shift = shiftById.get(entry.shiftId); if (!shift) { return err(new ShiftNotFoundError(`assignments[${index}].${entry.shiftId}`)); } const assignment = assignmentById.get(entry.assignmentId); if (!assignment || !isEffectiveOn(assignment, shift.date)) { return err(new AssignmentNotEffectiveError(`assignments[${index}].${entry.assignmentId}`)); } } // At most one ACTIVE placement per (shift, assignment) — the rule createShiftPlacement // enforces, applied to the batch in both directions: within the input, and against rows already // stored. A generation pass replaying an overlapping window is the realistic way this happens, // and a duplicate would double-count the person in getShiftVariance. The pairs are checked with // one query over the batch's shifts rather than one per entry; SUPERSEDED / CANCELLED rows are // not matched, so re-placing someone released earlier stays allowed. const batchPairs = new Set(); for (const [index, entry] of input.assignments.entries()) { const pair = `${entry.shiftId}:${entry.assignmentId}`; if (batchPairs.has(pair)) { return err(new ShiftPlacementAlreadyExistsError(`assignments[${index}].${pair}`)); } batchPairs.add(pair); } const activePlacements = await db .selectFrom("ShiftPlacement") .select(["shiftId", "assignmentId"]) .where("shiftId", "in", shiftIds) .where("status", "=", "ACTIVE") .execute(); const storedPairs = new Set(activePlacements.map((p) => `${p.shiftId}:${p.assignmentId}`)); for (const [index, entry] of input.assignments.entries()) { const pair = `${entry.shiftId}:${entry.assignmentId}`; if (storedPairs.has(pair)) { return err(new ShiftPlacementAlreadyExistsError(`assignments[${index}].${pair}`)); } } // Validation succeeded for every entry: insert all rows in one transaction (all-or-nothing). const now = new Date(); const shiftPlacements = await db .insertInto("ShiftPlacement") .values( input.assignments.map((entry) => { const { shiftId, assignmentId, ...entryCustomFields } = entry; return { ...(entryCustomFields as Record), shiftId, assignmentId, status: "ACTIVE" as const, supersededById: null, assignedAt: now, releasedAt: null, provenance: "GENERATED" as const, }; }), ) .returningAll() .execute(); return ok({ shiftPlacements }); }