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 { selectVoidedEventIds } from "../lib/_voidStatus"; import { addDays, toWorkDate } from "../lib/dayBreaker"; import { OverlappingBlockError } from "../lib/errors.generated"; import type { TimeClassificationStrategy } from "../lib/timeClassificationStrategy"; import type { ReportedBlockType } from "./declareReportedBlock"; export interface FormReportedBlocksInput { assignmentId: string; workDate: Date; /** IANA timezone the day-breaker assigns calendar days in. Defaults to UTC when the caller * omits it; the punch-triggered caller resolves the governing timezone (Assignment -> * Position -> Site, falling back to a default when no Position is site-bound) and passes it * explicitly (see reformReportedBlocksAround). */ timezone?: string; } // Maps each opening punch to the closing punch that terminates the interval it opens. const OPEN_TO_CLOSE_EVENT_TYPE: Record = { CLOCK_IN: "CLOCK_OUT", BREAK_START: "BREAK_END", STEP_OUT: "STEP_IN", }; // Maps each closing punch back to the opener type it can close. const CLOSE_TO_OPEN_EVENT_TYPE: Record = { CLOCK_OUT: "CLOCK_IN", BREAK_END: "BREAK_START", STEP_IN: "STEP_OUT", }; const OPEN_EVENT_TYPE_TO_BLOCK_TYPE: Record = { CLOCK_IN: "WORK", BREAK_START: "BREAK", STEP_OUT: "STEP_OUT", }; const DAY_MS = 24 * 60 * 60 * 1000; interface FormedBlock { blockType: ReportedBlockType; startAt: Date; endAt: Date; workDate: Date; } /** * Function: formReportedBlocks * Description: Forms or refreshes punch-derived ReportedTimeBlocks for an * Assignment around a reference workday by pairing raw TimeClockEvents * through the day-breaker into WORK, BREAK, and STEP_OUT intervals. Each * formed block is assigned to its OWN workDate (the day-breaker day of its * opening punch, not necessarily the reference date), so a single call can * touch the reference day and the day before it (overnight spillover) without * mis-stamping or double-counting an unrelated day's shift. Re-running * formation for a workday regenerates rather than duplicates its blocks. */ export async function run>( db: Transaction, input: FormReportedBlocksInput & CF, ctx: CommandContext, workforceQueries: ResolveWorkRuleQueries, strategy?: TimeClassificationStrategy, ) { const { assignmentId, workDate, timezone, ...customFields } = input; // Defensive: `workDate` is meant to be a day-breaker workDate (UTC midnight), and the // neighborhood matching below relies on exact getTime() equality against toWorkDate() // results. Truncate any stray time component to UTC midnight so a caller passing a // non-midnight Date can't silently turn block matching into a no-op (nothing reformed). const referenceDate = new Date( Date.UTC(workDate.getUTCFullYear(), workDate.getUTCMonth(), workDate.getUTCDate()), ); // 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. // Only these two days are ever written to by this call; a block whose day-breaker // workDate falls outside this neighborhood belongs to some other day's own trigger // and is left untouched here (this is what prevents double-counting an already-formed // later shift that the fetch window below happens to also see). const neighborhoodWorkDates = [addDays(referenceDate, -1), referenceDate]; // Generous day-breaker fetch window. `referenceDate` is a UTC-midnight calendar key, but // the local days we form for (referenceDate-1 and referenceDate in `timezone`) straddle it // by up to the zone's UTC offset (+14h..-12h): the previous local day's opening punch can // land well before UTC midnight, and a shift opened on the reference local day can close // overnight into the next local day. We over-fetch two days back and three ahead so no // in-neighborhood opener/closer is missed for non-UTC Sites; blocks whose day-breaker // workDate falls outside the neighborhood are filtered out of writes below. const windowStart = addDays(referenceDate, -2); const windowEnd = addDays(referenceDate, 3); const events = await db .selectFrom("TimeClockEvent") .selectAll() .where("assignmentId", "=", assignmentId) .where("occurredAt", ">=", windowStart) .where("occurredAt", "<", windowEnd) .orderBy("occurredAt", "asc") .execute(); // Voided punches are excluded from pairing. Effective void state lives in the separate // append-only TimeClockEventVoid table (ADR-023), keyed by the latest record per event. const voidedEventIds = await selectVoidedEventIds( db, events.map((e) => e.id), ); const activeEvents = events.filter((e) => !voidedEventIds.has(e.id)); // Pair independently per opener type (WORK/BREAK/STEP_OUT can nest - a BREAK_START/END pair // inside a still-open CLOCK_IN...CLOCK_OUT span must form on its own, not be skipped over by // a single flat sequential scan that jumps straight from an opener to its own closer). const pendingOpen: Partial> = {}; const formedBlocks: FormedBlock[] = []; for (const event of activeEvents) { const closeType = OPEN_TO_CLOSE_EVENT_TYPE[event.eventType]; if (closeType) { // An opener with one of its own type already pending (e.g. two CLOCK_INs with no // CLOCK_OUT between them) replaces the stale pending one rather than pairing with a // later, unrelated closer. pendingOpen[event.eventType] = event; continue; } const openType = CLOSE_TO_OPEN_EVENT_TYPE[event.eventType]; const openEvent = openType ? pendingOpen[openType] : undefined; if (!openType || !openEvent) { // A stray closer with no pending opener of its type; skip it. continue; } const blockType = OPEN_EVENT_TYPE_TO_BLOCK_TYPE[openType]; if (!blockType) { continue; } // A punch pair must span a positive interval; a degenerate pair (closer at or before its opener) // would form an empty/inverted block. Punch-derived blocks carry raw device timestamps, so the // whole-minute rule the declared paths use does not apply here (M08). if ( validateReportedInterval(assignmentId, openEvent.occurredAt, event.occurredAt, { requireWholeMinute: false, }) ) { delete pendingOpen[openType]; continue; } formedBlocks.push({ blockType, startAt: openEvent.occurredAt, endAt: event.occurredAt, // A block's true workday is the day-breaker day of its OPENING punch (the shift/break/ // step-out started there), regardless of which calendar day its closer lands on. workDate: toWorkDate(openEvent.occurredAt, timezone), }); delete pendingOpen[openType]; } // Any opener still pending here has no closer anywhere in the fetch window yet: the normal, // benign state of "still clocked in / still on break", not a data error. It's simply left // unformed - it will be formed once its closing punch arrives. const reportedTimeBlocks: Array>[number]> = []; const notOpenWorkDates: Date[] = []; const overlapErrorWorkDates: Date[] = []; // Every workday actually re-formed this run (its prior punch-derived blocks were cleared and the // new set — possibly empty — written), so calculation is re-derived even for a day that reformed // to zero blocks (e.g. after voiding a CLOCK_OUT). Keyed off the reformed days, not just the days // that produced blocks, so an emptied day's stale CalculatedTimeBlock/Timecard totals are cleared. const reformedWorkDates: Date[] = []; for (const touchedWorkDate of neighborhoodWorkDates) { const blocksForDay = formedBlocks.filter( (b) => b.workDate.getTime() === touchedWorkDate.getTime(), ); const timecard = await db .selectFrom("Timecard") .select(["status"]) .where("assignmentId", "=", assignmentId) .where("periodStart", "<=", touchedWorkDate) .where("periodEnd", ">=", touchedWorkDate) .executeTakeFirst(); if (timecard && timecard.status !== "OPEN") { // OPEN-only: a non-OPEN (SUBMITTED/APPROVED/LOCKED) period must never be silently // rewritten by automatic re-formation; skip this day entirely and let the caller // decide whether/how to surface it (reopenTimecard for SUBMITTED/APPROVED, or // recordHistoricalCorrection for LOCKED), same guard as correctReportedBlock. A day // with no covering Timecard is still (re)formed, as before. if (blocksForDay.length > 0) notOpenWorkDates.push(touchedWorkDate); continue; } const formedWorkBlocksForDay = blocksForDay.filter((b) => b.blockType === "WORK"); if (formedWorkBlocksForDay.length > 0) { // Declared/corrected/imported (non punch-derived) current WORK blocks aren't touched // by re-formation, so newly-formed WORK blocks must not overlap them. const existingDeclaredWork = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", assignmentId) .where("workDate", "=", touchedWorkDate) .where("blockType", "=", "WORK") .where("supersededByBlockId", "is", null) .where("sourceKind", "!=", "PUNCH_DERIVED") .execute(); const overlaps = existingDeclaredWork.some((existing) => formedWorkBlocksForDay.some( (formed) => existing.startAt < formed.endAt && existing.endAt > formed.startAt, ), ); if (overlaps) { overlapErrorWorkDates.push(touchedWorkDate); continue; } } const inserted = await insertBlocksForWorkDate( db, assignmentId, touchedWorkDate, blocksForDay, customFields as Record, ); reportedTimeBlocks.push(...inserted); reformedWorkDates.push(touchedWorkDate); } if (reportedTimeBlocks.length === 0 && overlapErrorWorkDates.length > 0) { // Every touched day that had something to write hit a real conflict: surface it rather // than silently reporting success with nothing written. return err(new OverlappingBlockError(assignmentId)); } // Re-derive each touched workday's CalculatedTimeBlocks in the same transaction (domain // invariant, centralized in recalculateReportedWrite): this is what makes a punch (via // reformReportedBlocksAround) or an explicit re-form flow through to CalculatedTimeBlocks // without a separate calculateTimeBlocks call. See declareReportedBlock for the skip policy. const recalc = await recalculateReportedWrite( db, workforceQueries, assignmentId, reformedWorkDates, ctx, { strategy }, ); if (!recalc.ok) { return recalc; } return ok({ reportedTimeBlocks, notOpenWorkDates, overlapErrorWorkDates }); } async function insertBlocksForWorkDate( db: Transaction, assignmentId: string, workDate: Date, blocks: FormedBlock[], customFields: Record, ) { // Re-running formation for the same workday regenerates rather than duplicates blocks: // clear the previous current punch-derived blocks for this Assignment/workday first. Safe // to hard-delete: a corrected block is re-tagged sourceKind = MANUAL on correction (see // correctReportedBlock), so a current PUNCH_DERIVED row here can never be the target of a // historical supersede reference. await db .deleteFrom("ReportedTimeBlock") .where("assignmentId", "=", assignmentId) .where("workDate", "=", workDate) .where("sourceKind", "=", "PUNCH_DERIVED") .where("supersededByBlockId", "is", null) .execute(); if (blocks.length === 0) { return []; } return db .insertInto("ReportedTimeBlock") .values( blocks.map((block) => ({ ...customFields, assignmentId, workDate, blockType: block.blockType, sourceKind: "PUNCH_DERIVED" as const, startAt: block.startAt, endAt: block.endAt, correctionReason: null, supersededByBlockId: null, })), ) .returningAll() .execute(); } export { DAY_MS };