import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { AssignmentNotFoundError, PositionNotFoundError, PositionNotEffectiveError, OutsideEmploymentRangeError, PositionHeadcountExceededError, EmploymentNotFoundError, TransferDateBeforeCurrentGenerationStartError, } from "../lib/errors.generated"; export interface TransferAssignmentInput { id: string; toPositionId: string; effectiveDate: Date; } /** * Function: run * Description: Moves an Assignment to a new destination Position as a new * effective-dated generation (ADR-013) — closes the current open generation * (effectiveEnd = effectiveDate - 1 day) and inserts a new generation sharing * the same versionOf, rather than patching the row in place. */ export async function run(db: Transaction, input: TransferAssignmentInput, _ctx: CommandContext) { const current = await db .selectFrom("Assignment") .selectAll() .where("id", "=", input.id) .where("effectiveEnd", "is", null) .forUpdate() .executeTakeFirst(); if (!current) return err(new AssignmentNotFoundError(input.id)); // The transfer date must fall strictly after the current generation's effectiveStart. Otherwise // closing the current generation at `effectiveDate - 1` would invert its range // (effectiveEnd < effectiveStart) and place the new generation before the old one — the same guard // updateWorkerEmployment enforces. if (input.effectiveDate.getTime() <= current.effectiveStart.getTime()) { return err(new TransferDateBeforeCurrentGenerationStartError(input.id)); } const destinationPosition = await db .selectFrom("Position") .selectAll() .where("id", "=", input.toPositionId) .executeTakeFirst(); if (!destinationPosition) return err(new PositionNotFoundError(input.toPositionId)); const isPositionEffective = destinationPosition.effectiveStart <= input.effectiveDate && (destinationPosition.effectiveEnd === null || destinationPosition.effectiveEnd >= input.effectiveDate); if (!isPositionEffective) return err(new PositionNotEffectiveError(input.toPositionId)); const employment = await db .selectFrom("WorkerEmployment") .selectAll() .where("id", "=", current.workerEmploymentId) .executeTakeFirst(); if (!employment) return err(new EmploymentNotFoundError(current.workerEmploymentId)); const isWithinEmploymentRange = employment.effectiveStart <= input.effectiveDate && (employment.effectiveEnd === null || employment.effectiveEnd >= input.effectiveDate); if (!isWithinEmploymentRange) return err(new OutsideEmploymentRangeError(employment.id)); if (current.isPrimary) { const openPrimaryAssignments = await db .selectFrom("Assignment") .select("id") .where("positionId", "=", input.toPositionId) .where("isPrimary", "=", true) .where("effectiveEnd", "is", null) .execute(); if (openPrimaryAssignments.length >= destinationPosition.headcount) { return err(new PositionHeadcountExceededError(input.toPositionId)); } } const newEffectiveEnd = new Date(input.effectiveDate); newEffectiveEnd.setUTCDate(newEffectiveEnd.getUTCDate() - 1); await db .updateTable("Assignment") .set({ effectiveEnd: newEffectiveEnd }) .where("id", "=", current.id) .execute(); const inserted = await db .insertInto("Assignment") .values({ workerEmploymentId: current.workerEmploymentId, positionId: input.toPositionId, isPrimary: current.isPrimary, effectiveStart: input.effectiveDate, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ assignment: inserted }); }