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:
parent
29524b362d
commit
840c18cf6e
2 changed files with 289 additions and 0 deletions
139
src/lib/plant-hours-schema.test.ts
Normal file
139
src/lib/plant-hours-schema.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
150
src/lib/plant-hours-schema.ts
Normal file
150
src/lib/plant-hours-schema.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue