diff --git a/src/lib/plant-hours-schema.test.ts b/src/lib/plant-hours-schema.test.ts new file mode 100644 index 0000000..31fc27e --- /dev/null +++ b/src/lib/plant-hours-schema.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { + computePlantHoursFields, + validatePlantHoursRow, + type PlantHoursRowInput, +} from "./plant-hours-schema"; + +const baseVehicle = { + id: "veh-1", + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + unit_type: "plant" as const, + site: "site-1", + site_project_code: "P-001", +}; + +const validRow: PlantHoursRowInput = { + date_logged: "2026-08-28", + vehicle_qr: "QR-A", + vehicle_desc: "Generator X", + start_date: "2026-08-24", + end_date: "2026-08-28", + start_time: "06:00", + end_time: "17:00", + start_hours: 1200, + end_hours: 1231, + hours: 31, + notes: "", + hour_meter_budget: 40, + cost_per_hour: 185, +}; + +describe("computePlantHoursFields", () => { + it("computes hours from end_hours − start_hours", () => { + expect( + computePlantHoursFields({ ...validRow, hours: undefined }).hours, + ).toBe(31); + }); + it("computes value = hours * cost_per_hour rounded to 2dp", () => { + expect(computePlantHoursFields(validRow).value).toBe(5735); + // 31 * 185 = 5735 exactly + }); + it("flags over_budget when hours > hour_meter_budget", () => { + expect(computePlantHoursFields(validRow).over_budget).toBe(false); + expect( + computePlantHoursFields({ ...validRow, hour_meter_budget: 20 }) + .over_budget, + ).toBe(true); + }); + it("flags hours_mismatch when operator hours differ by > 0.05", () => { + expect(computePlantHoursFields(validRow).hours_mismatch).toBe(false); + expect( + computePlantHoursFields({ ...validRow, hours: 30 }).hours_mismatch, + ).toBe(true); + }); +}); + +describe("validatePlantHoursRow", () => { + it("returns no errors for a valid row + matching vehicle", () => { + const r = validatePlantHoursRow(validRow, baseVehicle, new Set()); + expect(r.errors).toEqual([]); + expect(r.warnings).toEqual([]); + }); + + it("rejects unknown QR", () => { + const r = validatePlantHoursRow( + { ...validRow, vehicle_qr: "QR-XYZ" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/unknown_vehicle_qr/); + }); + + it("rejects end_date < start_date", () => { + const r = validatePlantHoursRow( + { ...validRow, end_date: "2026-08-23" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/bad_date_range/); + }); + + it("rejects end_hours < start_hours", () => { + const r = validatePlantHoursRow( + { ...validRow, end_hours: 1199 }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/end_hours_lt_start_hours/); + }); + + it("rejects bad time format", () => { + const r = validatePlantHoursRow( + { ...validRow, start_time: "not a time" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/bad_time_format/); + }); + + it("rejects when cell cost_per_hour blank AND vehicle has none", () => { + const r = validatePlantHoursRow( + { ...validRow, cost_per_hour: null }, + { ...baseVehicle, cost_per_hour: 0 }, + new Set(), + ); + expect(r.errors[0]).toMatch(/missing_cost_per_hour/); + }); + + it("falls back to vehicle.cost_per_hour when cell blank", () => { + const r = validatePlantHoursRow( + { ...validRow, cost_per_hour: null }, + baseVehicle, + new Set(), + ); + expect(r.errors).toEqual([]); + expect(r.computed.cost_per_hour).toBe(185); + }); + + it("rejects duplicate week", () => { + const r = validatePlantHoursRow( + validRow, + baseVehicle, + new Set(["QR-A|2026-08-24"]), + ); + expect(r.errors[0]).toMatch(/duplicate_week/); + }); + + it("warns (does not error) on plant row for fuel-typed vehicle", () => { + const r = validatePlantHoursRow( + validRow, + { ...baseVehicle, unit_type: "fuel" }, + new Set(), + ); + expect(r.errors).toEqual([]); + expect(r.warnings[0]).toMatch(/plant_row_for_fuel_vehicle/); + }); +}); \ No newline at end of file diff --git a/src/lib/plant-hours-schema.ts b/src/lib/plant-hours-schema.ts new file mode 100644 index 0000000..46236ef --- /dev/null +++ b/src/lib/plant-hours-schema.ts @@ -0,0 +1,150 @@ +/** + * Plant-hours row validator + derived-field helpers. + * + * The import route calls validatePlantHoursRow() once per row. + * It returns errors (which block insertion) and warnings (which + * surface in the Attention Required panel but don't block). + * + * Pure — no I/O, no PocketBase calls. The route owns the vehicle + * cache and the duplicate-set; this file just consumes them. + */ +import type { UnitType } from "./pb-types"; + +export interface PlantHoursRowInput { + date_logged: string; + vehicle_qr: string; + vehicle_desc: string; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; + start_hours: number; + end_hours: number; + hours: number | null | undefined; + notes: string; + hour_meter_budget: number; + cost_per_hour: number | null; +} + +export interface PlantHoursVehicleLookup { + id: string; + qr_code_id: string; + description: string; + cost_per_hour: number; + unit_type: UnitType; + site: string; + site_project_code: string; +} + +export interface PlantHoursComputedFields { + hours: number; + cost_per_hour: number; + value: number; + over_budget: boolean; + hours_mismatch: boolean; + vehicle_unit_type_at_import: UnitType; +} + +export interface PlantHoursValidationResult { + errors: string[]; + warnings: string[]; + computed: PlantHoursComputedFields; +} + +const HOURS_MISMATCH_TOLERANCE = 0.05; + +/** Compute derived fields. Assumes the row passed basic type parsing. */ +export function computePlantHoursFields( + row: PlantHoursRowInput, + vehicleUnitType: UnitType = "plant", +): PlantHoursComputedFields { + const hours = row.end_hours - row.start_hours; + const operatorHours = row.hours ?? hours; + const costPerHour = row.cost_per_hour ?? 0; + const value = Math.round(hours * costPerHour * 100) / 100; + return { + hours, + cost_per_hour: costPerHour, + value, + over_budget: hours > row.hour_meter_budget, + hours_mismatch: + Math.abs(operatorHours - hours) > HOURS_MISMATCH_TOLERANCE, + vehicle_unit_type_at_import: vehicleUnitType, + }; +} + +/** + * Validate one parsed row against the vehicle lookup and the + * set of (vehicle_qr, start_date) keys already imported in this batch. + */ +export function validatePlantHoursRow( + row: PlantHoursRowInput, + vehicle: PlantHoursVehicleLookup | undefined, + duplicatesThisBatch: Set, +): PlantHoursValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!vehicle || row.vehicle_qr !== vehicle.qr_code_id) { + errors.push(`unknown_vehicle_qr: ${row.vehicle_qr || "(empty)"}`); + return { + errors, + warnings, + computed: computePlantHoursFields(row), + }; + } + + if (row.end_date < row.start_date) { + errors.push("bad_date_range: end_date is before start_date"); + } + if (row.end_hours < row.start_hours) { + errors.push( + `end_hours_lt_start_hours: ${row.end_hours} < ${row.start_hours}`, + ); + } + if ( + row.start_time !== null && + !/^([01]\d|2[0-3]):[0-5]\d$/.test(row.start_time) + ) { + errors.push(`bad_time_format: start_time "${row.start_time}"`); + } + if ( + row.end_time !== null && + !/^([01]\d|2[0-3]):[0-5]\d$/.test(row.end_time) + ) { + errors.push(`bad_time_format: end_time "${row.end_time}"`); + } + + // Cost-per-hour resolution. + let resolvedCostPerHour: number; + if (row.cost_per_hour !== null && row.cost_per_hour > 0) { + resolvedCostPerHour = row.cost_per_hour; + } else if (vehicle.cost_per_hour > 0) { + resolvedCostPerHour = vehicle.cost_per_hour; + } else { + errors.push("missing_cost_per_hour: cell empty and vehicle has no rate"); + resolvedCostPerHour = 0; + } + + const dupKey = `${vehicle.qr_code_id}|${row.start_date}`; + if (duplicatesThisBatch.has(dupKey)) { + errors.push( + `duplicate_week: ${vehicle.qr_code_id} already logged for ${row.start_date}`, + ); + } + + // Type-mismatch warning (lenient — doesn't block insertion). + if (vehicle.unit_type === "fuel") { + warnings.push( + `plant_row_for_fuel_vehicle: ${vehicle.qr_code_id} is typed as fuel; row was imported as audit-only data`, + ); + } + + const rowForCompute: PlantHoursRowInput = { + ...row, + cost_per_hour: resolvedCostPerHour, + }; + const computed = computePlantHoursFields(rowForCompute, vehicle.unit_type); + + return { errors, warnings, computed }; +} \ No newline at end of file