import { ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { run as endAssignment } from "./endAssignment"; import { run as transferAssignment } from "./transferAssignment"; export interface ApplyDueAppointmentsInput { /** Cutoff; appointments effective on or before this apply. Defaults to now. */ asOf?: Date; } /** * Function: applyDueAppointments * Description: Carries out the Assignment side effect of future-dated appointments whose * effectiveDate has now arrived. recordAppointment applies CREATE_* and non-future CHANGE/END * immediately, but a future-dated CHANGE/END is only appended as upcoming (appliedAt null); without * this batch it would never actually transfer or end the assignment on its date (C01). Run * periodically (e.g. daily): for each pending appointment due on or before `asOf`, it applies the * transfer/termination, re-points a CHANGE entry to the new generation, and stamps appliedAt so the * appointment is never applied twice. Best-effort: an entry whose assignment change is no longer * valid (e.g. already ended by an earlier appointment) is left pending and counted as failed. */ export async function run(db: Transaction, input: ApplyDueAppointmentsInput, ctx: CommandContext) { const now = new Date(); const asOf = input.asOf ?? now; // Pending appointments due by asOf, oldest first so several changes to the same assignment apply // in chronological order. const due = await db .selectFrom("AppointmentHistory") .innerJoin("AppointmentType", "AppointmentType.id", "AppointmentHistory.appointmentTypeId") .select([ "AppointmentHistory.id as id", "AppointmentHistory.assignmentId as assignmentId", "AppointmentHistory.toPositionId as toPositionId", "AppointmentHistory.effectiveDate as effectiveDate", "AppointmentType.action as action", ]) .where("AppointmentHistory.appliedAt", "is", null) .where("AppointmentHistory.effectiveDate", "<=", asOf) .orderBy("AppointmentHistory.effectiveDate", "asc") .orderBy("AppointmentHistory.id", "asc") .execute(); let applied = 0; let failed = 0; for (const entry of due) { if (entry.action === "CHANGE_ASSIGNMENT") { if (!entry.toPositionId) { failed++; continue; } const result = await transferAssignment( db, { id: entry.assignmentId, toPositionId: entry.toPositionId, effectiveDate: entry.effectiveDate, }, ctx, ); if (!result.ok) { failed++; continue; } // transferAssignment closed the old generation and opened a new one. Mark this appointment // applied and re-point it to the new generation... const newAssignmentId = result.value.assignment.id; const oldAssignmentId = entry.assignmentId; await db .updateTable("AppointmentHistory") .set({ assignmentId: newAssignmentId, appliedAt: now }) .where("id", "=", entry.id) .execute(); // ...and re-point any OTHER still-pending appointments that referenced the now-closed // generation to the new one, so a subsequent future-dated CHANGE/END on the same assignment // series is applied against the open generation rather than failing on the closed one (its // referenced generation would otherwise be stale forever). await db .updateTable("AppointmentHistory") .set({ assignmentId: newAssignmentId }) .where("assignmentId", "=", oldAssignmentId) .where("appliedAt", "is", null) .execute(); // Keep the in-memory queue consistent for the remainder of this run. for (const pending of due) { if (pending.assignmentId === oldAssignmentId) { pending.assignmentId = newAssignmentId; } } applied++; } else if (entry.action === "END_ASSIGNMENT") { const result = await endAssignment( db, { id: entry.assignmentId, effectiveEnd: entry.effectiveDate }, ctx, ); if (!result.ok) { failed++; continue; } await db .updateTable("AppointmentHistory") .set({ appliedAt: now }) .where("id", "=", entry.id) .execute(); applied++; } else { // CREATE_* are applied at record time and never pending; stamp defensively so a stray one is // not rescanned every run. await db .updateTable("AppointmentHistory") .set({ appliedAt: now }) .where("id", "=", entry.id) .execute(); applied++; } } return ok({ applied, failed }); }