const MINUTE_MS = 60_000; export type VarianceStatus = "PENDING" | "OVERTIME" | "LATE_ARRIVAL" | "EARLY_LEAVE" | "ON_TIME"; export interface ShiftSegmentInput { plannedStartAt: Date; plannedEndAt: Date; breakMinutes: number; } /** * Function: computePlannedMinutes * Description: Planned working minutes for a shift = the sum over its segments of each segment's * gross planned span minus its break. This is the scheduling domain's definition of "planned * work", owned by the module rather than recomputed in the app layer. */ export function computePlannedMinutes(segments: ShiftSegmentInput[]): number { return segments.reduce((sum, segment) => { const grossSpanMinutes = (segment.plannedEndAt.getTime() - segment.plannedStartAt.getTime()) / MINUTE_MS; return sum + (grossSpanMinutes - segment.breakMinutes); }, 0); } export interface ActualBlockInput { startAt: Date; endAt: Date; minutes: number; } export interface PlacementVariance { variance: VarianceStatus; actualMinutes: number; actualStartAt: Date | undefined; actualEndAt: Date | undefined; } /** * Function: classifyPlacementVariance * Description: Classifies one placement's planned-vs-actual variance from the actual * CalculatedTimeBlocks and the shift's planned envelope. The classification rule — PENDING when * there are no actuals, otherwise the priority order OVERTIME (worked more than planned) > * LATE_ARRIVAL (first actual block starts after the planned start) > EARLY_LEAVE (last actual * block ends before the planned end) > ON_TIME — is the scheduling domain's business rule and * lives here so it is unit-testable in the module rather than embedded in the app resolver. * * The actuals are supplied by the caller because CalculatedTimeBlock is owned by time-tracking: * the app resolver composes the cross-module read (shiftSchedule can neither query that table nor * receive an injected query — erp-kit queries take no dependencies) and passes the blocks in. */ export function classifyPlacementVariance(args: { plannedMinutes: number; plannedStartAt: Date; plannedEndAt: Date; blocks: ActualBlockInput[]; }): PlacementVariance { const { plannedMinutes, plannedStartAt, plannedEndAt, blocks } = args; if (blocks.length === 0) { return { variance: "PENDING", actualMinutes: 0, actualStartAt: undefined, actualEndAt: undefined, }; } const actualMinutes = blocks.reduce((sum, block) => sum + block.minutes, 0); const actualStartAt = blocks.reduce( (earliest, block) => (block.startAt.getTime() < earliest.getTime() ? block.startAt : earliest), blocks[0].startAt, ); const actualEndAt = blocks.reduce( (latest, block) => (block.endAt.getTime() > latest.getTime() ? block.endAt : latest), blocks[0].endAt, ); let variance: VarianceStatus; if (actualMinutes > plannedMinutes) { variance = "OVERTIME"; } else if (actualStartAt.getTime() > plannedStartAt.getTime()) { variance = "LATE_ARRIVAL"; } else if (actualEndAt.getTime() < plannedEndAt.getTime()) { variance = "EARLY_LEAVE"; } else { variance = "ON_TIME"; } return { variance, actualMinutes, actualStartAt, actualEndAt }; }