import { err, ok, type CommandContext } 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 { BlockContainmentViolationError, FutureIntervalError, InvalidBlockTypeError, OverlappingBlockError, TimecardNotOpenError, } from "../lib/errors.generated"; import type { TimeClassificationStrategy } from "../lib/timeClassificationStrategy"; const BLOCK_TYPES = ["WORK", "BREAK", "STEP_OUT"] as const; export type ReportedBlockType = (typeof BLOCK_TYPES)[number]; export interface DeclareReportedBlockInput { assignmentId: string; workDate: Date; blockType: ReportedBlockType; startAt: Date; endAt: Date; } /** * Function: declareReportedBlock * Description: Lets a worker or administrator manually declare a WORK, BREAK, * or STEP_OUT interval for a workday when no punch device exists, creating a * ReportedTimeBlock with sourceKind = MANUAL. */ export async function run>( db: Transaction, input: DeclareReportedBlockInput & CF, ctx: CommandContext, workforceQueries: ResolveWorkRuleQueries, strategy?: TimeClassificationStrategy, ) { const { assignmentId, workDate, blockType, startAt, endAt, ...customFields } = input; // assignmentId (workforce, cross-module) is validated downstream, not by FK alone: this command // triggers recalculateReportedWrite → resolveWorkRule → the injected workforce getAssignment, // which rejects an assignment that is absent or not in force on workDate — so no separate upfront // existence check is needed. if (!BLOCK_TYPES.includes(blockType)) { return err(new InvalidBlockTypeError(String(blockType))); } // A declared interval reflects something that already happened; unlike a punch device // recording occurredAt in real time, manual declaration has no such guardrail, so it's // rejected explicitly here rather than left to whatever a future closing punch happens to // conflict with. const now = new Date(); if (startAt > now || endAt > now) { return err(new FutureIntervalError(assignmentId)); } // A manually declared interval must be well-formed: a positive span on whole-minute boundaries // (shared with correction/import; punch-derived blocks skip the whole-minute rule) (M08). const intervalError = validateReportedInterval(assignmentId, startAt, endAt, { requireWholeMinute: true, }); if (intervalError) { return err(intervalError); } const timecard = await db .selectFrom("Timecard") .selectAll() .where("assignmentId", "=", assignmentId) .where("periodStart", "<=", workDate) .where("periodEnd", ">=", workDate) .executeTakeFirst(); // OPEN-only: manual declaration is allowed only while the covering Timecard is OPEN // (SUBMITTED/APPROVED require reopenTimecard first; LOCKED requires // recordHistoricalCorrection). A workDate with no covering Timecard is still declarable. if (timecard && timecard.status !== "OPEN") { return err(new TimecardNotOpenError(assignmentId)); } if (blockType === "WORK") { // Declaring a WORK block only adds coverage, so no containment check is // needed — only the WORK-vs-WORK non-overlap rule applies. const overlapping = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", assignmentId) .where("workDate", "=", workDate) .where("blockType", "=", "WORK") .where("supersededByBlockId", "is", null) .where("startAt", "<", endAt) .where("endAt", ">", startAt) .orderBy("id") .forUpdate() .execute(); if (overlapping.length > 0) { return err(new OverlappingBlockError(assignmentId)); } } else { // BREAK/STEP_OUT declaration must land within a covering current WORK block; // declaring a child before any covering WORK exists is rejected. Lock rows in // a deterministic (id-ordered) acquisition order to match correctReportedBlock // and avoid deadlocking with concurrent commands on the same day. const work = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", assignmentId) .where("workDate", "=", workDate) .where("blockType", "=", "WORK") .where("supersededByBlockId", "is", null) .orderBy("id") .forUpdate() .execute(); const contained = work.some((block) => block.startAt <= startAt && block.endAt >= endAt); if (!contained) { return err(new BlockContainmentViolationError(assignmentId)); } } const reportedTimeBlock = await db .insertInto("ReportedTimeBlock") .values({ ...(customFields as Record), assignmentId, workDate, blockType, sourceKind: "MANUAL", startAt, endAt, correctionReason: null, supersededByBlockId: null, }) .returningAll() .executeTakeFirstOrThrow(); // Re-derive the workday's CalculatedTimeBlocks in the same transaction (domain invariant, // centralized in recalculateReportedWrite). A WorkRule that does not resolve rolls the whole // declaration back; a workDate that legitimately precedes the Assignment defers calculation. const recalc = await recalculateReportedWrite( db, workforceQueries, assignmentId, [reportedTimeBlock.workDate], ctx, { strategy }, ); if (!recalc.ok) { return recalc; } return ok({ reportedTimeBlock }); }