import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { shiftPlacementLifecycle } from "../db/shiftPlacement.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { isEffectiveOn, type GetAssignmentDep } from "../lib/_workforceDeps"; import { MissingRequiredFieldError, ShiftPlacementNotFoundError, ShiftPlacementNotActiveError, ShiftCancelledError, ShiftNotFoundError, AssignmentNotEffectiveError, ShiftPlacementAlreadyExistsError, } from "../lib/errors.generated"; export interface SwapShiftPlacementInput { /** The ACTIVE placement being replaced. */ id: string; /** The Assignment taking over the slot. */ assignmentId: string; } /** * Function: swapShiftPlacement * Description: Replaces who staffs a slot in one atomic step — the outgoing placement becomes * SUPERSEDED and points at the incoming one, which is created ACTIVE on the same Shift. A * substitution ("Y, cover for X tonight") is a change of plan, not an actuals event: nobody has * worked yet, so the shiftSchedule is the only place that can hold it. Doing it as one command is what * keeps the two halves from drifting — a release without a replacement silently reopens the slot. * * Note the scope line: variance stops reporting X as a no-show, which is correct — X's sickness * is a leave-management fact, not a scheduling one. The SUPERSEDED row remains the audit trail * that X was originally planned here. */ export async function run>( db: Transaction, // The custom fields land on the *incoming* placement: the outgoing row is only retired, and // rewriting its extension values would edit history. input: SwapShiftPlacementInput & CF, ctx: CommandContext, deps: GetAssignmentDep, ) { const { id, assignmentId, ...customFields } = input; if (!id || !assignmentId) { return err(new MissingRequiredFieldError(id ? "assignmentId" : "id")); } const outgoing = await db .selectFrom("ShiftPlacement") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!outgoing) { return err(new ShiftPlacementNotFoundError(id)); } // A swap is the ACTIVE -> SUPERSEDED edge of the generated lifecycle (docs/model/ // ShiftPlacement.md's State Transitions table). The module's own error is kept: a caller told // "not active" learns why the swap was refused, which a generic invalid-transition code hides. const outgoingNextStatus = shiftPlacementLifecycle.tryTransition(outgoing.status, "supersede"); if (!outgoingNextStatus) { return err(new ShiftPlacementNotActiveError(id)); } const shift = await db .selectFrom("Shift") .selectAll() .where("id", "=", outgoing.shiftId) .forUpdate() .executeTakeFirst(); if (!shift) { return err(new ShiftNotFoundError(outgoing.shiftId)); } if (shift.cancelledAt !== null) { return err(new ShiftCancelledError(outgoing.shiftId)); } // The replacement has to be someone actually posted on that date; a swap is exactly where a // hurried substitution could otherwise put an ineligible person on the slot. const incomingAssignment = await deps.getAssignment(db, { id: assignmentId }, ctx); if (!incomingAssignment.ok || !isEffectiveOn(incomingAssignment.value.assignment, shift.date)) { return err(new AssignmentNotEffectiveError(assignmentId)); } // Swapping onto a slot the replacement already staffs would leave them counted twice, so the // same one-ACTIVE-per-(shift, assignment) rule createShiftPlacement enforces applies here. const alreadyPlaced = await db .selectFrom("ShiftPlacement") .select("id") .where("shiftId", "=", outgoing.shiftId) .where("assignmentId", "=", assignmentId) .where("status", "=", "ACTIVE") .executeTakeFirst(); if (alreadyPlaced) { return err(new ShiftPlacementAlreadyExistsError(`${outgoing.shiftId}:${assignmentId}`)); } const now = new Date(); // A swap is always a human decision, so the replacement is MANUAL even when the placement it // replaces was GENERATED — that is what keeps a regeneration pass from overwriting it. const incoming = await db .insertInto("ShiftPlacement") .values({ ...(customFields as Record), shiftId: outgoing.shiftId, assignmentId, status: "ACTIVE", supersededById: null, assignedAt: now, releasedAt: null, provenance: "MANUAL", }) .returningAll() .executeTakeFirstOrThrow(); const superseded = await db .updateTable("ShiftPlacement") .set({ status: outgoingNextStatus, supersededById: incoming.id, releasedAt: now }) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); return ok({ shiftPlacement: incoming, supersededShiftPlacement: superseded }); }