import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { logger } from "@tailor-platform/sdk/runtime"; import type { Transaction } from "../generated/kysely-tailordb"; import { type ResolveWorkRuleQueries, resolveWorkRule } from "./_resolveWorkRule"; import { StrategyOutputInvalidError, WorkRuleNotFoundError } from "./errors.generated"; import { jpTimeClassificationStrategy } from "./jpTimeClassificationStrategy"; import type { TimeClassificationStrategy } from "./timeClassificationStrategy"; import type { CalculatedTimeBlockCreate, Schema, TimeEntryCode } from "./types"; type TimeEntryCodeRow = TimeEntryCode; type TimeEntryCodeCategory = TimeEntryCodeRow["category"]; /** * Function: deriveWorkdayBlocks * Description: Shared Reported → Calculated derivation for one Assignment + workday, used by * calculateTimeBlocks and recalculateRange. Resolves the WorkRule through the workforce * context (most-specific WorkRuleAssignment in force on workDate), classifies the day * against CompanyHoliday, decomposes the current ReportedTimeBlocks into categorized spans, * and binds each span to a TimeEntryCode by category key (never display name, ADR-015). * * Returns the insertable CalculatedTimeBlock rows (possibly empty when the day has no * current reported blocks) — persistence and prior-block discard stay with the caller. */ export async function deriveWorkdayBlocks( db: Transaction, workforceQueries: ResolveWorkRuleQueries, input: { assignmentId: string; workDate: Date }, ctx: CommandContext, strategy: TimeClassificationStrategy = jpTimeClassificationStrategy, ) { const resolution = await resolveWorkRule( db, workforceQueries, input.assignmentId, input.workDate, ctx, ); if (!resolution.ok) { return resolution; } const { workRule, resolvedTargetType } = resolution.value; // Holiday classification is scoped to the calendar the resolved WorkRule points at; a rule // with no holidayCalendarId applies no calendar, so no date is treated as a holiday. const holiday = workRule.holidayCalendarId ? await db .selectFrom("CompanyHoliday") .selectAll() .where("calendarId", "=", workRule.holidayCalendarId) .where("holidayDate", "=", input.workDate) .executeTakeFirst() : undefined; const holidayKind = holiday?.holidayKind ?? null; const reportedBlocks = await db .selectFrom("ReportedTimeBlock") .selectAll() .where("assignmentId", "=", input.assignmentId) .where("workDate", "=", input.workDate) .where("supersededByBlockId", "is", null) .execute(); // Delegate categorization + premium stacking to the injected strategy (default JP). The core // owns no period concept; a strategy needing period context queries it via `db` itself. const classified = await strategy.classify( db, { assignmentId: input.assignmentId, workDate: input.workDate, reportedBlocks: reportedBlocks.map((block) => ({ id: block.id, blockType: block.blockType, startAt: block.startAt, endAt: block.endAt, })), workRule, holidayKind, }, ctx, ); // The strategy is a swappable seam (a host may inject its own). Validate every span it emits before // any of it is persisted, so a misbehaving strategy can never write a corrupt or untraceable // CalculatedTimeBlock: minutes must be a non-negative integer, the interval must be well-formed // (startAt <= endAt), and every sourceReportedBlockId must reference a current reported block for // this workday (M16). const reportedBlockIdSet = new Set(reportedBlocks.map((block) => block.id)); for (const span of classified) { if (!Number.isInteger(span.minutes) || span.minutes < 0) { return err(new StrategyOutputInvalidError(`minutes=${span.minutes}`)); } if (span.startAt.getTime() > span.endAt.getTime()) { return err( new StrategyOutputInvalidError( `interval ${span.startAt.toISOString()}..${span.endAt.toISOString()}`, ), ); } for (const sourceId of span.sourceReportedBlockIds) { if (!reportedBlockIdSet.has(sourceId)) { return err(new StrategyOutputInvalidError(`sourceReportedBlockId ${sourceId}`)); } } } // Bind each span's category to a TimeEntryCode by stable key (ADR-024 #5, "category / key"). // The resolved WorkRule's premiumRatePercent entries can pin a category to a specific // TimeEntryCode key; when pinned, that key wins regardless of how many other codes share the // category. Only an unpinned category falls back to the category-wide lookup, where zero or // more than one code is unresolvable configuration — same failure class as a missing WorkRule // generation (Process Flow: "Rules resolvable?"); picking one arbitrarily among duplicates // would make the calculation depend on DB row order instead of a stable key. const timeEntryCodes = await db.selectFrom("TimeEntryCode").selectAll().execute(); const codesByCategory = new Map(); const codesByKey = new Map(); for (const code of timeEntryCodes) { codesByKey.set(code.key, code); const existing = codesByCategory.get(code.category); if (existing) { existing.push(code); } else { codesByCategory.set(code.category, [code]); } } const resolveCode = (spanCategory: TimeEntryCodeCategory): TimeEntryCodeRow | undefined => { const pinnedKeys = [ ...new Set( workRule.premiumRatePercent .filter((entry) => entry.category === spanCategory && entry.key != null) .map((entry) => entry.key as string), ), ]; if (pinnedKeys.length > 1) { // Two distinct keys pinned for one category is ambiguous configuration. return undefined; } if (pinnedKeys.length === 1) { const pinned = codesByKey.get(pinnedKeys[0]); return pinned?.category === spanCategory ? pinned : undefined; } const codes = codesByCategory.get(spanCategory) ?? []; return codes.length === 1 ? codes[0] : undefined; }; const rows: CalculatedTimeBlockCreate[] = []; for (const span of classified) { const code = resolveCode(span.codeCategory); if (!code) { return err(new WorkRuleNotFoundError(`${workRule.id}:${span.codeCategory}`)); } rows.push({ assignmentId: input.assignmentId, workDate: input.workDate, category: span.category, startAt: span.startAt, endAt: span.endAt, minutes: span.minutes, timeEntryCodeKey: code.key, payCodeKey: code.payMapKey, workRuleId: workRule.id, calculationTagKeys: [`RULE_TARGET_${resolvedTargetType}`, ...span.calculationTagKeys], sourceReportedBlockIds: span.sourceReportedBlockIds, }); } return ok({ rows, hasReportedBlocks: reportedBlocks.length > 0 }); } /** * Function: writeWorkdayCalculation * Description: Discards the prior CalculatedTimeBlocks for one Assignment + workday and inserts * the freshly derived rows ("replace, never accumulate"). Does NOT refresh Timecard totals — * the caller batches that so a multi-workday recalc refreshes each covering OPEN Timecard once * rather than once per workday. */ async function writeWorkdayCalculation( db: Transaction, assignmentId: string, workDate: Date, rows: CalculatedTimeBlockCreate[], ) { await db .deleteFrom("CalculatedTimeBlock") .where("assignmentId", "=", assignmentId) .where("workDate", "=", workDate) .execute(); return rows.length > 0 ? await db.insertInto("CalculatedTimeBlock").values(rows).returningAll().execute() : []; } /** * Function: persistWorkdayCalculation * Description: Discards the prior CalculatedTimeBlocks for one Assignment + workday, inserts the * freshly derived rows, then refreshes the covering OPEN Timecard totals. Single-workday callers * (the calculateTimeBlocks command) use this directly; multi-workday recalc (recalculateReportedWrite) * writes each day then refreshes once at the end. */ export async function persistWorkdayCalculation( db: Transaction, assignmentId: string, workDate: Date, rows: CalculatedTimeBlockCreate[], ) { const calculatedTimeBlocks = await writeWorkdayCalculation(db, assignmentId, workDate, rows); await refreshOpenTimecardTotals(db, assignmentId, [workDate]); return calculatedTimeBlocks; } /** * Function: recalculateReportedWrite * Description: Re-derives CalculatedTimeBlocks for the workday(s) a Reported-block command just * wrote, centralizing the "Reported change → Calculated re-derivation" domain invariant that * used to be copy-pasted into the declare / correct / form resolvers (and was silently missing * from import, leaving bulk-imported blocks uncalculated — issue #37). Absorbing it here means * the same-class gap cannot recur behind a new writer. * * Shared skip policy: * - ASSIGNMENT_NOT_FOUND: the workDate can legitimately precede the Assignment's effectiveStart * (e.g. an overnight day-breaker workDate landing the day before a brand-new Assignment * starts). The Reported write itself is valid; calculation is deferred — to be picked up once * the Assignment's window covers it or via a recalculateRange sweep — rather than rejecting * the write. The workday is reported back in `deferredWorkDates`. * - WORK_RULE_NOT_FOUND: a genuine rule-configuration gap. By default it is propagated so the * caller (declare / correct / form) rolls the whole write back. Bulk import passes * `deferUnresolvable: true` so one unresolvable day does not roll back the other * successfully-imported rows; that day's calculation is deferred to a later sweep instead. * * A workday with no current ReportedTimeBlocks is skipped without error and without deleting its * prior CalculatedTimeBlocks (clearing a day's stale blocks is an explicit recalculateRange * concern, not a side effect of an empty re-derivation). */ export async function recalculateReportedWrite( db: Transaction, workforceQueries: ResolveWorkRuleQueries, assignmentId: string, workDates: Date[], ctx: CommandContext, options?: { deferUnresolvable?: boolean; strategy?: TimeClassificationStrategy }, ) { const deferUnresolvable = options?.deferUnresolvable ?? false; const strategy = options?.strategy ?? jpTimeClassificationStrategy; const uniqueWorkDates = [...new Map(workDates.map((date) => [date.getTime(), date])).values()]; // Expand each written workday to the strategy's recalculation window. A daily strategy leaves it // as the day itself; a period-aggregating strategy (e.g. weekly overtime) returns the whole period, // so a single-day reported-block write re-derives every neighbour whose calculation depends on it // rather than leaving them stale (C07/C11). A change on one day of a week thus recalculates the week. const workDatesToRecalc = new Map(); for (const workDate of uniqueWorkDates) { const window = strategy.recalculationWindow ? await strategy.recalculationWindow(db, { assignmentId, workDate }, ctx) : [workDate]; for (const day of window) { workDatesToRecalc.set(day.getTime(), day); } } const deferredWorkDates: Date[] = []; const persistedWorkDates: Date[] = []; for (const workDate of workDatesToRecalc.values()) { const derived = await deriveWorkdayBlocks( db, workforceQueries, { assignmentId, workDate }, ctx, strategy, ); if (!derived.ok) { if (derived.error.code === "TIME_TRACKING_ASSIGNMENT_NOT_FOUND") { logger.warn("time-tracking derivation deferred", { command: "recalculateReportedWrite", reason: "no Assignment effective", assignmentId, workDate: workDate.toISOString(), }); deferredWorkDates.push(workDate); continue; } // Defer ONLY the explicitly-tolerated unresolvable-rule case. Any other derivation error // (including WORK_RULE_NOT_FOUND when deferral is off) propagates and rolls the write back, // so a future error added to deriveWorkdayBlocks is never silently swallowed by import. if (deferUnresolvable && derived.error.code === "TIME_TRACKING_WORK_RULE_NOT_FOUND") { logger.warn("time-tracking derivation deferred", { command: "recalculateReportedWrite", reason: "no WorkRule resolves", assignmentId, workDate: workDate.toISOString(), }); deferredWorkDates.push(workDate); continue; } return err(derived.error); } // Always write the derived set — including the empty set. A day that a Reported write left with // no current ReportedTimeBlocks (e.g. voiding a punch removed the last block that formed it) // must have its prior CalculatedTimeBlocks cleared and its Timecard total re-aggregated to zero, // not left stale. writeWorkdayCalculation deletes-then-(re)inserts, so an empty `rows` clears the // day; the covering OPEN Timecard is refreshed below because the day is in persistedWorkDates. await writeWorkdayCalculation(db, assignmentId, workDate, derived.value.rows); persistedWorkDates.push(workDate); } // Refresh the covering OPEN Timecards ONCE over all persisted workdays. refreshOpenTimecardTotals // re-aggregates each covering Timecard across its whole period, so doing it per workday would // re-aggregate the same Timecard repeatedly for a multi-day write (e.g. a bulk import). if (persistedWorkDates.length > 0) { await refreshOpenTimecardTotals(db, assignmentId, persistedWorkDates); } return ok({ deferredWorkDates }); } /** * Function: refreshOpenTimecardTotals * Description: Refreshes the denormalized category totals (regular/overtime/night/holiday * minutes) of every OPEN Timecard whose period covers any of the given workdays, summing the * covered CalculatedTimeBlocks per category. Non-OPEN Timecards are left untouched — their * totals are part of the sign-off snapshot and only change via historical correction. */ export async function refreshOpenTimecardTotals( db: Transaction, assignmentId: string, workDates: Date[], ) { if (workDates.length === 0) return; const minDate = new Date(Math.min(...workDates.map((date) => date.getTime()))); const maxDate = new Date(Math.max(...workDates.map((date) => date.getTime()))); const timecards = await db .selectFrom("Timecard") .selectAll() .where("assignmentId", "=", assignmentId) .where("status", "=", "OPEN") .where("periodStart", "<=", maxDate) .where("periodEnd", ">=", minDate) .forUpdate() .execute(); for (const timecard of timecards) { const blocks = await db .selectFrom("CalculatedTimeBlock") .select(["category", "minutes"]) .where("assignmentId", "=", assignmentId) .where("workDate", ">=", timecard.periodStart) .where("workDate", "<=", timecard.periodEnd) .execute(); // Roll the open category keys up into a per-category totals list — no fixed JP columns, so any // strategy's categories are surfaced. Sorted by category for a stable, deterministic order. const totals: Record = {}; for (const block of blocks) { totals[block.category] = (totals[block.category] ?? 0) + block.minutes; } const categoryTotals = Object.entries(totals) .map(([category, minutes]) => ({ category, minutes })) .sort((a, b) => (a.category < b.category ? -1 : a.category > b.category ? 1 : 0)); await db .updateTable("Timecard") .set({ categoryTotals }) .where("id", "=", timecard.id) .execute(); } }