import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { lockApprovedTimecards } from "../lib/_lockTransition"; import { NoTimecardsInPeriodError } from "../lib/errors.generated"; export interface CloseTimecardPeriodInput { periodStart: string; // exact match periodEnd: string; // exact match assignmentIds?: string[]; // omitted = every Timecard in the period; app resolves org→assignmentIds dryRun?: boolean; // preview the partition without locking anything } export interface CloseTimecardPeriodResult { locked: Array<{ timecardId: string; assignmentId: string }>; skipped: Array<{ timecardId: string; assignmentId: string; status: "OPEN" | "SUBMITTED" }>; alreadyLocked: string[]; fullyClosed: boolean; // skipped is empty } /** * Function: closeTimecardPeriod * Description: Period close (APPROVED→LOCKED) scoped to an exact periodStart/periodEnd, * optionally narrowed to a set of assignmentIds. Organization scoping lives in the app * layer (workforce resolves org→assignmentIds); the module only matches on period. * * Partial close: every APPROVED card is locked, non-APPROVED (OPEN/SUBMITTED) cards are * reported as skipped, and already-LOCKED cards are a no-op (alreadyLocked). The close * never aborts the whole batch on a straggler. An empty match set is NO_TIMECARDS_IN_PERIOD. * dryRun returns the same partition without performing any UPDATE. Single transaction. * * periodStart/periodEnd are `date` columns, so the string inputs are coerced to Date the * same way declareReportedBlock/correctReportedBlock treat workDate. */ export async function run(db: Transaction, input: CloseTimecardPeriodInput, ctx: CommandContext) { let query = db .selectFrom("Timecard") .selectAll() .where("periodStart", "=", new Date(input.periodStart)) .where("periodEnd", "=", new Date(input.periodEnd)); if (input.assignmentIds && input.assignmentIds.length > 0) { query = query.where("assignmentId", "in", input.assignmentIds); } const timecards = await query.forUpdate().execute(); if (timecards.length === 0) { return err(new NoTimecardsInPeriodError(`${input.periodStart}..${input.periodEnd}`)); } const locked: CloseTimecardPeriodResult["locked"] = []; const skipped: CloseTimecardPeriodResult["skipped"] = []; const alreadyLocked: string[] = []; const idsToLock: string[] = []; for (const timecard of timecards) { if (timecard.status === "APPROVED") { idsToLock.push(timecard.id); locked.push({ timecardId: timecard.id, assignmentId: timecard.assignmentId }); } else if (timecard.status === "OPEN" || timecard.status === "SUBMITTED") { skipped.push({ timecardId: timecard.id, assignmentId: timecard.assignmentId, status: timecard.status, }); } else { // LOCKED — already closed, no-op (not an error). alreadyLocked.push(timecard.id); } } if (!input.dryRun && idsToLock.length > 0) { await lockApprovedTimecards(db, idsToLock, ctx.actorId); } return ok({ locked, skipped, alreadyLocked, fullyClosed: skipped.length === 0, } satisfies CloseTimecardPeriodResult); }