import { 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 type { TimeClassificationStrategy } from "../lib/timeClassificationStrategy"; import type { ReportedTimeBlock, Schema } from "../lib/types"; import type { ReportedBlockType } from "./declareReportedBlock"; const BLOCK_TYPES = ["WORK", "BREAK", "STEP_OUT"] as const; export interface ImportReportedBlockRow { assignmentId: string; workDate: Date; blockType: ReportedBlockType; startAt: Date; endAt: Date; } export interface ImportReportedBlocksInput { rows: ImportReportedBlockRow[]; } export type ImportReportedBlocksRejectReason = | "INVALID_ROW" | "TIMECARD_NOT_OPEN" | "OVERLAPPING_BLOCK"; export interface RejectedImportRow { row: ImportReportedBlockRow; reason: ImportReportedBlocksRejectReason; } /** * Function: importReportedBlocks * Description: Bulk-creates declared ReportedTimeBlocks from an external * source, with sourceKind = IMPORT, without disturbing any existing block. * Rows that fail validation are rejected individually and do not block valid * rows in the same batch. */ export async function run>( db: Transaction, input: ImportReportedBlocksInput & CF, ctx: CommandContext, workforceQueries: ResolveWorkRuleQueries, strategy?: TimeClassificationStrategy, ) { const { rows, ...customFields } = input; // each row's assignmentId (workforce, cross-module) is validated downstream, not by FK alone: // import triggers recalculateReportedWrite → resolveWorkRule → the injected workforce // getAssignment, which rejects an assignment that is absent or not in force on workDate. const reportedTimeBlocks: ReportedTimeBlock[] = []; const rejectedRows: RejectedImportRow[] = []; for (const row of rows) { if (!BLOCK_TYPES.includes(row.blockType)) { rejectedRows.push({ row, reason: "INVALID_ROW" }); continue; } // A malformed interval — empty/inverted, or not on whole-minute boundaries — is rejected as an // INVALID_ROW, applying the same well-formedness rule as manual declaration/correction (M08). if ( validateReportedInterval(row.assignmentId, row.startAt, row.endAt, { requireWholeMinute: true, }) ) { rejectedRows.push({ row, reason: "INVALID_ROW" }); continue; } const timecard = await db .selectFrom("Timecard") .selectAll() .where("assignmentId", "=", row.assignmentId) .where("periodStart", "<=", row.workDate) .where("periodEnd", ">=", row.workDate) .executeTakeFirst(); // OPEN-only: a row is accepted only while its covering Timecard is OPEN // (SUBMITTED/APPROVED require reopenTimecard first; LOCKED requires // recordHistoricalCorrection). A row whose workDate has no covering Timecard is // still importable, as before. if (timecard && timecard.status !== "OPEN") { rejectedRows.push({ row, reason: "TIMECARD_NOT_OPEN" }); continue; } if (row.blockType === "WORK") { const overlapping = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", row.assignmentId) .where("workDate", "=", row.workDate) .where("blockType", "=", "WORK") .where("supersededByBlockId", "is", null) .where("startAt", "<", row.endAt) .where("endAt", ">", row.startAt) .forUpdate() .execute(); if (overlapping.length > 0) { rejectedRows.push({ row, reason: "OVERLAPPING_BLOCK" }); continue; } } const reportedTimeBlock = await db .insertInto("ReportedTimeBlock") .values({ ...(customFields as Record), assignmentId: row.assignmentId, workDate: row.workDate, blockType: row.blockType, sourceKind: "IMPORT", startAt: row.startAt, endAt: row.endAt, correctionReason: null, supersededByBlockId: null, }) .returningAll() .executeTakeFirstOrThrow(); reportedTimeBlocks.push(reportedTimeBlock); } // Re-derive the CalculatedTimeBlocks for every workday this batch actually wrote to (issue // #37: bulk-imported blocks were previously left uncalculated because the import path never // re-derived). A batch can span multiple Assignments, so group the written workdays per // Assignment. Bulk import is best-effort per-row, so `deferUnresolvable` keeps one day whose // WorkRule does not resolve from rolling back the other successfully-imported rows — that // day's calculation is deferred to a later recalculateRange sweep, matching the // ASSIGNMENT_NOT_FOUND deferral the shared helper already applies. const workDatesByAssignment = new Map(); for (const block of reportedTimeBlocks) { const existing = workDatesByAssignment.get(block.assignmentId); if (existing) { existing.push(block.workDate); } else { workDatesByAssignment.set(block.assignmentId, [block.workDate]); } } for (const [assignmentId, workDates] of workDatesByAssignment) { const recalc = await recalculateReportedWrite( db, workforceQueries, assignmentId, workDates, ctx, { deferUnresolvable: true, strategy, }, ); // With deferUnresolvable, ASSIGNMENT_NOT_FOUND and WORK_RULE_NOT_FOUND are tolerated (deferred). // Any OTHER derivation error is genuinely unexpected and must roll the batch back rather than // being swallowed into an ok() with stale CalculatedTimeBlocks. if (!recalc.ok) { return recalc; } } return ok({ reportedTimeBlocks, rejectedRows }); }