MEGAINFRA-WEBAPP/src/lib/import-parse.ts
DEls 077dc35cf2 refactor(imports): extract shared import-parse helpers + add parseTime
Moves normHeader/resolveColumn/parseDate/toNumber out of fuel-import-parse
into a new src/lib/import-parse.ts so plant-hours can reuse them. Adds
parseTime (HH:MM, 24h, single-digit hour normalised). Adds the
PLANT_COL_ALIASES table for the plant-hours route. fuel-import-parse
becomes a thin re-export shim — the fuel-logs route and its existing
test file are unchanged.
2026-08-28 12:41:59 +02:00

124 lines
No EOL
4.8 KiB
TypeScript

/**
* Shared parsing helpers for the import routes (fuel-logs, plant-hours).
* Extracted from src/lib/fuel-import-parse.ts so that plant-hours can
* reuse the column-resolution + date-parsing logic and add a new
* parseTime helper without duplicating code.
*
* All four helpers are stateless and side-effect free. fuel-import-parse
* re-exports the three legacy helpers so existing consumers keep working.
*/
/** Header aliases for the plant-hours import (13 logical columns). */
export const PLANT_COL_ALIASES: Record<string, string[]> = {
date_logged: ["date logged", "date_logged", "date"],
vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr_code_id"],
vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"],
start_date: ["start date", "start_date"],
end_date: ["end date", "end_date"],
start_time: ["start time", "start_time"],
end_time: ["end time", "end_time"],
start_hours: ["start hours", "start_hours"],
end_hours: ["end hours", "end_hours"],
hours: ["hours"],
notes: ["notes"],
hour_meter: ["hour meter", "hour_meter", "hour_meter_budget", "budget"],
cost_per_hour: ["cost / hr", "cost/hr", "cost_per_hour", "cost"],
};
/** Header aliases for the fuel-logs import (legacy, kept for the fuel route). */
export const FUEL_COL_ALIASES: Record<string, string[]> = {
date: ["date logged", "date_logged", "date"],
vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr code id", "qr_code_id"],
vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"],
odo: ["km's/ hours", "kms/ hours", "km/hours", "odo_reading", "odometer", "hour_meter", "hour meter"],
litres: ["litres pumped", "litres", "liters"],
site: ["current site", "site", "current_site"],
consumption: ["latest consumption", "latest_consumption", "consumption"],
};
/** Normalise a header for fuzzy matching. */
export function normHeader(s: unknown): string {
return typeof s === "string"
? s.replace(/\s+/g, " ").trim().toLowerCase()
: "";
}
/**
* Resolve the original header text for a logical column key.
* Returns the EXACT header string the spreadsheet used (so the row
* lookup uses the same key the source file provided), or null if no
* alias matched.
*/
export function resolveColumn(
headers: string[],
key: keyof typeof PLANT_COL_ALIASES | keyof typeof FUEL_COL_ALIASES,
aliasMap: Record<string, string[]> = PLANT_COL_ALIASES as Record<string, string[]>,
): string | null {
const target = aliasMap[key].map(normHeader);
for (const h of headers) {
if (target.includes(normHeader(h))) return h;
}
return null;
}
/**
* Parse the date column into an ISO `YYYY-MM-DD` string.
* Primary: `DD/MM/YYYY`. Falls back to `YYYY-MM-DD` and Excel serials.
*/
export function parseDate(input: unknown): string | null {
if (input === null || input === undefined) return null;
if (typeof input === "number" && Number.isFinite(input) && input > 25569) {
const d = new Date(Math.round((input - 25569) * 86400 * 1000));
if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10);
}
const s = String(input).trim();
if (!s) return null;
let m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s);
if (m) {
const dd = Number(m[1]);
const mm = Number(m[2]);
const yyyy = Number(m[3]);
if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) {
const iso = new Date(Date.UTC(yyyy, mm - 1, dd));
if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10);
}
return null;
}
m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
if (m) {
const iso = new Date(
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])),
);
if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10);
}
return null;
}
/** Coerce a cell value to a finite number, or null if empty / non-numeric. */
export function toNumber(input: unknown): number | null {
if (input === null || input === undefined) return null;
if (typeof input === "number") return Number.isFinite(input) ? input : null;
if (typeof input === "string") {
const trimmed = input.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
return null;
}
/**
* Parse a time cell into `HH:MM` (24h). Returns null on malformed input.
* Single-digit hours are normalised to two digits (`6:00` → `06:00`).
*/
export function parseTime(input: unknown): string | null {
if (input === null || input === undefined) return null;
const s = String(input).trim();
if (!s) return null;
const m = /^(\d{1,2}):(\d{2})$/.exec(s);
if (!m) return null;
const hh = Number(m[1]);
const mm = Number(m[2]);
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null;
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
}