import { type CommandContext, err, ok } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { recalculateReportedWrite } from "../lib/_deriveCalculatedBlocks"; import { validateReportedInterval } from "../lib/_reportedBlockInterval"; import type { ResolveWorkRuleQueries } from "../lib/_resolveWorkRule"; import { BlockAlreadySupersededError, BlockContainmentViolationError, BlockNotFoundError, CorrectionReasonRequiredError, FutureIntervalError, OverlappingBlockError, TimecardNotOpenError, } from "../lib/errors.generated"; import type { TimeClassificationStrategy } from "../lib/timeClassificationStrategy"; import type { ReportedBlockType } from "./declareReportedBlock"; export interface CorrectReportedBlockInput { targetBlockId: string; startAt: Date; endAt: Date; correctionReason: string; } /** * Function: correctReportedBlock * Description: Corrects a prior ReportedTimeBlock by creating a new * replacement block and marking the prior one superseded, preserving the * original declaration as readable history (ADR-014). Only allowed while the * covering Timecard is OPEN; a SUBMITTED/APPROVED card must be reopened first and * a LOCKED card requires recordHistoricalCorrection instead. */ export async function run>( db: Transaction, input: CorrectReportedBlockInput & CF, ctx: CommandContext, workforceQueries: ResolveWorkRuleQueries, strategy?: TimeClassificationStrategy, ) { const { targetBlockId, startAt, endAt, correctionReason, ...customFields } = input; if (!correctionReason || correctionReason.trim().length === 0) { return err(new CorrectionReasonRequiredError(targetBlockId)); } // A correction reflects something that already happened; allowing a future startAt/endAt // would let a manual correction silently conflict with (or pre-empt) a real punch that // hasn't happened yet. const now = new Date(); if (startAt > now || endAt > now) { return err(new FutureIntervalError(targetBlockId)); } // The corrected interval must be well-formed: a positive span on whole-minute boundaries, the // same rule declaration and import enforce (M08). const intervalError = validateReportedInterval(targetBlockId, startAt, endAt, { requireWholeMinute: true, }); if (intervalError) { return err(intervalError); } // Scope read (no lock): the target's assignment/workDate are immutable, so an // unlocked read is enough to learn which day's blocks we must lock. const scope = await db .selectFrom("ReportedTimeBlock") .select(["assignmentId", "workDate"]) .where("id", "=", targetBlockId) .executeTakeFirst(); if (!scope) { return err(new BlockNotFoundError(targetBlockId)); } const timecard = await db .selectFrom("Timecard") .selectAll() .where("assignmentId", "=", scope.assignmentId) .where("periodStart", "<=", scope.workDate) .where("periodEnd", ">=", scope.workDate) .executeTakeFirst(); // OPEN-only: corrections flow via supersede only while the covering Timecard is OPEN. // A SUBMITTED/APPROVED card is frozen (reopenTimecard first); a LOCKED card is closed // (recordHistoricalCorrection instead). A workDate with no covering Timecard is still // correctable, as before. if (timecard && timecard.status !== "OPEN") { return err(new TimecardNotOpenError(targetBlockId)); } // Lock every current block for this assignment+workDate in a single query with a // deterministic (id-ordered) acquisition order. Concurrent corrections on // different blocks of the same day then queue on the same first row instead of // grabbing locks in opposite orders and deadlocking. The target is located // in-memory within this locked set rather than locked separately first. const dayBlocks = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", scope.assignmentId) .where("workDate", "=", scope.workDate) .where("supersededByBlockId", "is", null) .orderBy("id") .forUpdate() .execute(); const target = dayBlocks.find((block) => block.id === targetBlockId); if (!target) { // Present in the scope read but absent from the current set means it was // superseded (possibly by a concurrent correction we just waited on). return err(new BlockAlreadySupersededError(targetBlockId)); } const covers = (workStart: Date, workEnd: Date, childStart: Date, childEnd: Date) => workStart <= childStart && workEnd >= childEnd; const blockType = target.blockType as ReportedBlockType; if (blockType === "WORK") { const otherWork = dayBlocks.filter( (block) => block.blockType === "WORK" && block.id !== target.id, ); const overlapping = otherWork.filter((block) => block.startAt < endAt && block.endAt > startAt); if (overlapping.length > 0) { return err(new OverlappingBlockError(targetBlockId)); } // WORK-shrink orphan check: every current BREAK/STEP_OUT child must remain // covered by the new span or by some other current WORK block. const children = dayBlocks.filter( (block) => block.blockType === "BREAK" || block.blockType === "STEP_OUT", ); const orphaned = children.some( (child) => !covers(startAt, endAt, child.startAt, child.endAt) && !otherWork.some((work) => covers(work.startAt, work.endAt, child.startAt, child.endAt)), ); if (orphaned) { return err(new BlockContainmentViolationError(target.assignmentId)); } } else { // BREAK/STEP_OUT child-escape check: the corrected interval must fall within // a covering current WORK block. const work = dayBlocks.filter((block) => block.blockType === "WORK"); const contained = work.some((block) => covers(block.startAt, block.endAt, startAt, endAt)); if (!contained) { return err(new BlockContainmentViolationError(target.assignmentId)); } } const replacement = await db .insertInto("ReportedTimeBlock") .values({ ...(customFields as Record), assignmentId: target.assignmentId, workDate: target.workDate, blockType: target.blockType, // Always MANUAL, regardless of the target's own sourceKind: once a human corrects an // interval, it's no longer accurately described as auto-derived/imported, and formReportedBlocks' // bulk regenerate-by-delete only ever targets sourceKind = PUNCH_DERIVED rows. Keeping a // corrected block PUNCH_DERIVED would let the next punch silently wipe or FK-conflict with // the human correction (a corrected block can be the target of a supersede reference). sourceKind: "MANUAL", startAt, endAt, correctionReason, supersededByBlockId: null, }) .returningAll() .executeTakeFirstOrThrow(); await db .updateTable("ReportedTimeBlock") .set({ supersededByBlockId: replacement.id }) .where("id", "=", target.id) .execute(); // Re-derive the workday's CalculatedTimeBlocks in the same transaction (domain invariant, // centralized in recalculateReportedWrite). See declareReportedBlock for the skip policy. const recalc = await recalculateReportedWrite( db, workforceQueries, replacement.assignmentId, [replacement.workDate], ctx, { strategy }, ); if (!recalc.ok) { return recalc; } return ok({ reportedTimeBlock: replacement }); }