import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { deriveWorkdayBlocks, refreshOpenTimecardTotals } from "../lib/_deriveCalculatedBlocks"; import type { ResolveWorkRuleQueries } from "../lib/_resolveWorkRule"; import { InvalidRangeError } from "../lib/errors.generated"; import type { TimeClassificationStrategy } from "../lib/timeClassificationStrategy"; import type { CalculatedTimeBlockCreate, Schema } from "../lib/types"; export interface RecalculateRangeInput { assignmentId: string; start: Date; end: Date; } const DAY_MS = 24 * 60 * 60 * 1000; /** * Function: recalculateRange * Description: Re-derives CalculatedTimeBlocks over a date range (e.g. after a retroactive * correction or a rule change), discarding and regenerating only the affected period. * * Each workday in [start, end] that has current ReportedTimeBlocks is re-derived with the * same rule-resolution and categorization logic as calculateTimeBlocks — the WorkRule * generation is re-resolved per workDate, so a rule change mid-range applies from its * effective date (ADR-013, ADR-015). Days without current reported blocks regenerate to * empty. Covering OPEN Timecards have their denormalized category totals refreshed. * * Idempotent by construction: prior calculated blocks in the range are always discarded * before the new set is inserted, so re-running recalculateRange over the same range with * the same current ReportedTimeBlocks reproduces the same result without accumulating * duplicates. */ export async function run( db: Transaction, input: RecalculateRangeInput, ctx: CommandContext, workforceQueries: ResolveWorkRuleQueries, strategy?: TimeClassificationStrategy, ) { if (input.end.getTime() < input.start.getTime()) { return err( new InvalidRangeError( `${input.assignmentId}:${input.start.toISOString()}..${input.end.toISOString()}`, ), ); } const reportedBlocks = await db .selectFrom("ReportedTimeBlock") .select(["workDate"]) .where("assignmentId", "=", input.assignmentId) .where("workDate", ">=", input.start) .where("workDate", "<=", input.end) .where("supersededByBlockId", "is", null) .execute(); const workDatesWithBlocks = [ ...new Set(reportedBlocks.map((block) => block.workDate.getTime())), ].sort((a, b) => a - b); // Discard prior calculated blocks for the whole affected range before regenerating; each // workday in the range is regenerated (or left empty), never accumulated. await db .deleteFrom("CalculatedTimeBlock") .where("assignmentId", "=", input.assignmentId) .where("workDate", ">=", input.start) .where("workDate", "<=", input.end) .execute(); const rows: CalculatedTimeBlockCreate[] = []; for (const workDateMs of workDatesWithBlocks) { const derived = await deriveWorkdayBlocks( db, workforceQueries, { assignmentId: input.assignmentId, workDate: new Date(workDateMs) }, ctx, strategy, ); if (!derived.ok) { return derived; } rows.push(...derived.value.rows); } const calculatedTimeBlocks = rows.length > 0 ? await db.insertInto("CalculatedTimeBlock").values(rows).returningAll().execute() : []; const touchedDates: Date[] = []; for (let at = input.start.getTime(); at <= input.end.getTime(); at += DAY_MS) { touchedDates.push(new Date(at)); } await refreshOpenTimecardTotals(db, input.assignmentId, touchedDates); return ok({ calculatedTimeBlocks }); }