import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { AssignmentNotFoundError, InvalidPeriodError, TimecardPeriodOverlapError, } from "../lib/errors.generated"; import type { WorkforceQueries } from "../module"; /** Only the workforce query openTimecard needs: the Assignment the timecard covers. */ type OpenTimecardQueries = Pick; export interface OpenTimecardInput { assignmentId: string; periodStart: Date; periodEnd: Date; } /** * Function: openTimecard * Description: Creates a new Timecard for an Assignment and period, entering the lifecycle * at OPEN. Validates period ordering (INVALID_PERIOD) and rejects a period that overlaps an * existing Timecard for the same Assignment (TIMECARD_PERIOD_OVERLAP). The opening * `categoryTotals` are aggregated from the CalculatedTimeBlocks already covering the period, so a * Timecard opened over a period that already has calculated time reflects it immediately rather * than starting at zero and waiting for the next reported-block write to refresh it. */ export async function run( db: Transaction, input: OpenTimecardInput, ctx: CommandContext, workforceQueries: OpenTimecardQueries, ) { // Period ordering: start must not be after end. if (input.periodStart.getTime() > input.periodEnd.getTime()) { return err(new InvalidPeriodError(input.assignmentId)); } // The timecard must cover a workforce Assignment in force at the period start (the injected // query seam; FK integrity alone does not check the effective range). const assignmentResult = await workforceQueries.getAssignment( db, { id: input.assignmentId }, ctx, ); if ( !assignmentResult.ok || assignmentResult.value.assignment.effectiveStart.getTime() > input.periodStart.getTime() || (assignmentResult.value.assignment.effectiveEnd !== null && assignmentResult.value.assignment.effectiveEnd.getTime() < input.periodStart.getTime()) ) { return err(new AssignmentNotFoundError(input.assignmentId)); } // Reject a period that overlaps an existing Timecard for the same Assignment. Two ranges // [aStart, aEnd] and [bStart, bEnd] overlap iff aStart <= bEnd AND aEnd >= bStart. const overlapping = await db .selectFrom("Timecard") .select("id") .where("assignmentId", "=", input.assignmentId) .where("periodStart", "<=", input.periodEnd) .where("periodEnd", ">=", input.periodStart) .executeTakeFirst(); if (overlapping) { return err(new TimecardPeriodOverlapError(input.assignmentId)); } // Seed the opening totals from the CalculatedTimeBlocks already covering the period, rolled up // per category (any strategy's categories; sorted for a deterministic order — the same shape // refreshOpenTimecardTotals maintains on later reported-block writes). const coveredBlocks = await db .selectFrom("CalculatedTimeBlock") .select(["category", "minutes"]) .where("assignmentId", "=", input.assignmentId) .where("workDate", ">=", input.periodStart) .where("workDate", "<=", input.periodEnd) .execute(); const totals: Record = {}; for (const block of coveredBlocks) { totals[block.category] = (totals[block.category] ?? 0) + block.minutes; } const categoryTotals = Object.entries(totals) .map(([category, minutes]) => ({ category, minutes })) .sort((a, b) => (a.category < b.category ? -1 : a.category > b.category ? 1 : 0)); const timecard = await db .insertInto("Timecard") .values({ assignmentId: input.assignmentId, periodStart: input.periodStart, periodEnd: input.periodEnd, status: "OPEN", categoryTotals, submittedAt: null, approvedAt: null, approvedBy: null, historicalCorrection: false, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ timecard }); }