feat(plant-hours): row validator + derived field helpers

Pure validator covering all the error/warning cases from the spec:
unknown QR, bad date range, end_hours < start_hours, bad time format,
missing cost (cell + vehicle), duplicate week, and the type-mismatch
warning. computePlantHoursFields() derives hours/value/over_budget/
hours_mismatch and the vehicle_unit_type_at_import snapshot.
This commit is contained in:
DEls 2026-08-28 12:46:13 +02:00
parent 29524b362d
commit 840c18cf6e
2 changed files with 289 additions and 0 deletions

View file

@ -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/);
});
});

View file

@ -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<string>,
): 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 };
}