import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { DepartmentNotFoundError, HeadcountBelowCurrentOccupancyError, JobProfileNotFoundError, PositionNotFoundError, SiteNotFoundError, } from "../lib/errors.generated"; import type { OrganizationQueries } from "../module"; export type UpdatePositionInput = { id: string; /** Date the structural change takes effect; becomes the new generation's effectiveStart. */ effectiveStart: Date; departmentId?: string; siteId?: string | null; jobProfileId?: string; headcount?: number; }; /** * Function: updatePosition * * Records a structural change to a Position (Department, Site, JobProfile, * or headcount) as a new effective-dated generation, closing the prior one * (ADR-013). */ export async function run( db: Transaction, input: UpdatePositionInput, ctx: CommandContext, organizationQueries: Pick, ) { const current = await db .selectFrom("Position") .selectAll() .where("id", "=", input.id) .forUpdate() .executeTakeFirst(); if (!current) { return err(new PositionNotFoundError(input.id)); } if (input.departmentId !== undefined) { const { department } = ( await organizationQueries.getDepartment(db, { id: input.departmentId }, ctx) ).value; if (!department) { return err(new DepartmentNotFoundError(input.departmentId)); } } if (input.siteId !== undefined && input.siteId !== null) { const { site } = (await organizationQueries.getSite(db, { id: input.siteId }, ctx)).value; if (!site) { return err(new SiteNotFoundError(input.siteId)); } } if (input.jobProfileId !== undefined) { const jobProfile = await db .selectFrom("JobProfile") .selectAll() .where("id", "=", input.jobProfileId) .executeTakeFirst(); if (!jobProfile) { return err(new JobProfileNotFoundError(input.jobProfileId)); } } const nextHeadcount = input.headcount ?? current.headcount; if (input.headcount !== undefined) { const openPrimaryAssignments = await db .selectFrom("Assignment") .selectAll() .where("positionId", "=", current.id) .where("isPrimary", "=", true) .where("effectiveEnd", "is", null) .execute(); if (nextHeadcount < openPrimaryAssignments.length) { return err(new HeadcountBelowCurrentOccupancyError(input.id)); } } const previousGenerationEnd = new Date(input.effectiveStart); previousGenerationEnd.setUTCDate(previousGenerationEnd.getUTCDate() - 1); await db .updateTable("Position") .set({ effectiveEnd: previousGenerationEnd }) .where("id", "=", current.id) .execute(); const nextGenerationId = crypto.randomUUID(); const position = await db .insertInto("Position") .values({ id: nextGenerationId, departmentId: input.departmentId ?? current.departmentId, siteId: input.siteId !== undefined ? input.siteId : current.siteId, jobProfileId: input.jobProfileId ?? current.jobProfileId, headcount: nextHeadcount, effectiveStart: input.effectiveStart, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); // An open Assignment occupies the seat, not a particular structural generation of it. Assignment // rows reference a specific Position generation by id, so when this change takes effect now (or // retroactively) the open assignments must move onto the new generation — otherwise they keep // pointing at the just-closed generation and occupancy/headcount/vacancy queries (which join // Assignment.positionId to the generation in force) would see the new generation as empty and the // workers as orphaned on a closed one (C03). A future-dated change leaves them on the current // generation until that date: that generation is still the one in force today, so its occupancy // must not be emptied ahead of time. const now = new Date(); if (input.effectiveStart.getTime() <= now.getTime()) { await db .updateTable("Assignment") .set({ positionId: nextGenerationId }) .where("positionId", "=", current.id) .where("effectiveEnd", "is", null) .execute(); } return ok({ position }); }