feat(fuel): drop-in xlsx/csv import for pump sheets
The Aug 2026 pump sheet from The Hills (and any future sheet from
the same source) uses different column names and a different date
format than the legacy CSV the import route was built for. Adapts
the route so the same endpoint accepts either Excel (.xlsx) or CSV,
maps the new column names onto the existing FUEL_LOGS schema, and
persists their 'Latest Consumption' column verbatim.
What's new
• SheetJS (xlsx) replaces PapaParse for parsing. Single code path
handles both .xlsx and .csv uploads.
• Resolves columns case-insensitively against an alias table so
legacy CSVs (vehicle_description, odo_reading) still work
alongside the new shape (Date Logged, Vehicle QR, Km's/ Hours,
Litres Pumped, Current Site, Latest Consumption).
• Date parser: DD/MM/YYYY first (with leading-space tolerance),
falls back to YYYY-MM-DD, then to Excel serial numbers.
• Vehicle match: QR code first (clean identifier in the source
data), falls back to description text for legacy CSV.
• Site match: by sites.name (one round-trip, cached for the
import). project_code is derived from the resolved site; falls
back to the vehicle's project_code.
• Latest Consumption persisted as a new FUEL_LOGS field
(latest_consumption, number, optional). Empty source cells
stay empty rather than getting coerced to 0.
PocketBase change
• scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py
adds the new field. Idempotent; follows the pattern of the
2026-08-19 migration. Run it with PB_ADMIN_EMAIL +
PB_ADMIN_PASSWORD before importing the new sheets, or the
import will fail with 'latest_consumption is not a field'.
UI
• Import button now accepts .xlsx and CSV; SAMPLE_HEADERS +
SAMPLE_OPTIONAL show the new column expectations and the
DD/MM/YYYY date format hint.
• fuel-view.tsx shows a 'Consumption (src)' column on the row
table so operators can eyeball what their fuel team sent
against the app's recomputed variance.
Out of scope (intentionally untouched)
• vehicles, sites, data_import_batches and every other
collection — no schema changes there.
• PO / timesheets / delivery-notes imports.
• fuel/page.tsx business logic (variance calc, vehicle summaries).
Verified
• Dry-run against 'The Hills Diesel 1-24 August.xlsx': 134/134
rows parse cleanly, 0 errors. 12/134 rows correctly carry an
empty Latest Consumption (matches the source).
• vitest: 20/20 fuel-import-parse + 35/35 PO-schema tests pass.
• tsc --noEmit clean.
• One pre-existing failure in
src/app/api/purchase-orders/process-batch/route.test.ts
unrelated to fuel; confirmed failing on main before this change.
This commit is contained in:
parent
e0417e4560
commit
7bce0a4e80
10 changed files with 753 additions and 67 deletions
106
package-lock.json
generated
106
package-lock.json
generated
|
|
@ -28,7 +28,8 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
|
|
@ -5805,6 +5806,15 @@
|
|||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
|
|
@ -6357,6 +6367,19 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
|
|
@ -6438,6 +6461,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -6501,6 +6533,18 @@
|
|||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -7801,6 +7845,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
|
||||
|
|
@ -10766,6 +10819,18 @@
|
|||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
|
|
@ -12020,6 +12085,24 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
|
@ -12398,6 +12481,27 @@
|
|||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
|
|
|
|||
131
scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py
Executable file
131
scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py
Executable file
|
|
@ -0,0 +1,131 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-shot PocketBase migration applied via:
|
||||
|
||||
PB_URL=https://pb.mega.infra.apphub.co.za \\
|
||||
PB_ADMIN_EMAIL=admin@mega.co.za \\
|
||||
PB_ADMIN_PASSWORD=... \\
|
||||
python3 scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py
|
||||
|
||||
Adds a `latest_consumption` number field to FUEL_LOGS so the new
|
||||
Excel/CSV import flow can persist the "Latest Consumption" column from
|
||||
the pump sheet exactly as their fuel team reports it (rather than
|
||||
silently overwriting with the app's recomputed value).
|
||||
|
||||
Why persist at all if the UI already computes consumption?
|
||||
1. Audit trail: stores what their team actually filed, not what we
|
||||
computed — useful when the computed number disagrees with theirs
|
||||
on edge cases (zero distance, missing prior reading).
|
||||
2. Drift detection: later we can flag rows where stored and computed
|
||||
differ by more than a tolerance.
|
||||
3. Empty cells in the source (12/134 rows in the Aug 2026 sample)
|
||||
are preserved as NULL — we never invent a value.
|
||||
|
||||
Idempotent: re-running is safe; existing fields are skipped.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
COLLECTION = "fuel_logs"
|
||||
|
||||
NEW_FIELDS = {
|
||||
"latest_consumption": {
|
||||
"name": "latest_consumption",
|
||||
"type": "number",
|
||||
"required": False,
|
||||
# No min/max / noDecimal — mirrors the existing `litres` and
|
||||
# `odo_reading` fields exactly, so the same PB numeric-coercion
|
||||
# rules apply on write.
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def admin_auth(base_url: str) -> str:
|
||||
email = os.environ["PB_ADMIN_EMAIL"]
|
||||
password = os.environ["PB_ADMIN_PASSWORD"]
|
||||
body = json.dumps({"identity": email, "password": password}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/api/collections/_superusers/auth-with-password",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())["token"]
|
||||
|
||||
|
||||
def get_collection(base_url: str, token: str, name: str):
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/api/collections/{name}",
|
||||
headers={"Authorization": token},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def patch_collection(base_url: str, token: str, name: str, body: dict):
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/api/collections/{name}",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Authorization": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="PATCH",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body_txt = e.read().decode() if e.fp else ""
|
||||
raise RuntimeError(f"PATCH {name} failed: {e.code} {body_txt}") from e
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/")
|
||||
token = admin_auth(base_url)
|
||||
coll = get_collection(base_url, token, COLLECTION)
|
||||
if not coll:
|
||||
print(f"ERROR: collection {COLLECTION!r} not found on {base_url}")
|
||||
return 1
|
||||
|
||||
existing_fields = coll.get("fields", [])
|
||||
existing_field_names = {f["name"] for f in existing_fields}
|
||||
new_fields = [
|
||||
NEW_FIELDS[name] for name in NEW_FIELDS
|
||||
if name not in existing_field_names
|
||||
]
|
||||
if new_fields:
|
||||
merged_fields = existing_fields + new_fields
|
||||
print(f"Adding {len(new_fields)} new field(s) to {COLLECTION}: "
|
||||
f"{[f['name'] for f in new_fields]}")
|
||||
patch_collection(base_url, token, COLLECTION, {"fields": merged_fields})
|
||||
coll = get_collection(base_url, token, COLLECTION)
|
||||
existing_fields = coll.get("fields", [])
|
||||
else:
|
||||
print(f"No-op: all NEW_FIELDS already present on {COLLECTION}.")
|
||||
|
||||
# Final sanity dump for the operator.
|
||||
final = get_collection(base_url, token, COLLECTION)
|
||||
final_field_names = sorted(f["name"] for f in final.get("fields", []))
|
||||
print(f"\nFinal field list on {COLLECTION}: {final_field_names}")
|
||||
print("\nMigration complete.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyError as e:
|
||||
print(f"ERROR: missing environment variable {e}. "
|
||||
"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
|
@ -1,59 +1,153 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import Papa from "papaparse";
|
||||
import * as XLSX from "xlsx";
|
||||
import { pbAdmin } from "@/lib/pb";
|
||||
import {
|
||||
resolveColumn,
|
||||
parseDate,
|
||||
toNumber,
|
||||
} from "@/lib/fuel-import-parse";
|
||||
|
||||
/**
|
||||
* POST /api/fuel-logs/import
|
||||
*
|
||||
* Accepts multipart/form-data with a single `file` field containing a CSV.
|
||||
* Expected columns (header row required):
|
||||
* Accepts multipart/form-data with a single `file` field containing an
|
||||
* Excel (.xlsx) or CSV upload. The same route accepts both formats via
|
||||
* SheetJS, so the user can drop either file in.
|
||||
*
|
||||
* vehicle_description - matches VEHICLES.description (case-insensitive)
|
||||
* date_logged - yyyy-mm-dd
|
||||
* litres - number
|
||||
* odo_reading - number (odometer / hour meter)
|
||||
* Expected columns (header row required, case-insensitive, whitespace
|
||||
* normalised). The "Aug 2026" pump sheet from The Hills uses the
|
||||
* "primary" spelling; the others are legacy CSV aliases kept working
|
||||
* so old imports don't break:
|
||||
*
|
||||
* Optional:
|
||||
* project_code - falls back to the vehicle's project_code if blank
|
||||
* PRIMARY LEGACY ALIASES
|
||||
* ──────────────────────────── ────────────────────────────────
|
||||
* Date Logged date, date_logged
|
||||
* Vehicle QR vehicle_qr, qr, qr_code_id
|
||||
* Vehicle vehicle_description, description
|
||||
* Km's/ Hours odo_reading, odometer, hour_meter
|
||||
* Litres Pumped litres, liters
|
||||
* Current Site site, current_site
|
||||
* Latest Consumption latest_consumption, consumption (optional)
|
||||
*
|
||||
* The route:
|
||||
* 1. Creates one DATA_IMPORT_BATCHES record (batch_type=fuel_logs)
|
||||
* with the raw CSV attached for the audit trail.
|
||||
* 2. Resolves each row's vehicle_description → VEHICLES.id.
|
||||
* 3. Inserts a FUEL_LOGS row per valid input, linked to the batch.
|
||||
* Matching strategy:
|
||||
* - Vehicle: prefer QR (the clean identifier in the source data),
|
||||
* fall back to description text for legacy CSV imports.
|
||||
* - Site: match `sites.name` case-insensitive. One round-trip at
|
||||
* the top of the route; cached in a Map for the duration.
|
||||
*
|
||||
* Returns JSON: { batchId, imported, skipped, errors[] }.
|
||||
* Pipeline:
|
||||
* 1. Parse file (xlsx or csv) into rows.
|
||||
* 2. Create one DATA_IMPORT_BATCHES record (batch_type=fuel_logs)
|
||||
* with the original file attached for the audit trail. Status is
|
||||
* `processed` from birth - fuel imports stay synchronous (no AI,
|
||||
* fixed schema).
|
||||
* 3. For each row: validate, resolve vehicle+site, insert one
|
||||
* FUEL_LOGS row linked to the batch.
|
||||
*
|
||||
* Returns JSON: { batchId, totalRows, imported, skipped, errors[] }.
|
||||
*
|
||||
* Date parsing: pump sheets use DD/MM/YYYY (often with a leading space).
|
||||
* We try that first, fall back to YYYY-MM-DD for legacy CSV, then to
|
||||
* Excel date serials (numbers > 25569). Anything else is a row error.
|
||||
*
|
||||
* Pure helpers (resolveColumn, parseDate, toNumber) live in
|
||||
* src/lib/fuel-import-parse.ts so they're unit-testable without the
|
||||
* Next.js request lifecycle.
|
||||
*/
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
const csvText = await file.text();
|
||||
|
||||
const parsed = Papa.parse<Record<string, string>>(csvText, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (h) => h.trim(),
|
||||
});
|
||||
|
||||
if (parsed.errors.length > 0 && parsed.data.length === 0) {
|
||||
// SheetJS handles both .xlsx (binary) and .csv (text) from a single
|
||||
// call site; we just feed it the right "type".
|
||||
const filename = file.name.toLowerCase();
|
||||
let workbook: XLSX.WorkBook;
|
||||
try {
|
||||
if (filename.endsWith(".csv")) {
|
||||
const text = await file.text();
|
||||
workbook = XLSX.read(text, { type: "string" });
|
||||
} else {
|
||||
const buf = await file.arrayBuffer();
|
||||
workbook = XLSX.read(new Uint8Array(buf), { type: "array" });
|
||||
}
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "CSV could not be parsed",
|
||||
details: parsed.errors.slice(0, 5),
|
||||
error: "File could not be parsed as Excel or CSV",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workbook has no sheets" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// raw:true keeps numbers as numbers (matters for odo/litres/
|
||||
// latest_consumption) and dates as Excel serials - we handle both in
|
||||
// parseDate(). defval:"" so blank cells become "" not undefined.
|
||||
const rowsRaw = XLSX.utils.sheet_to_json<Record<string, unknown>>(
|
||||
workbook.Sheets[sheetName],
|
||||
{ raw: true, defval: "" },
|
||||
);
|
||||
|
||||
if (rowsRaw.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File contains no data rows" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const headers = Object.keys(rowsRaw[0] ?? {});
|
||||
const dateCol = resolveColumn(headers, "date");
|
||||
const qrCol = resolveColumn(headers, "vehicle_qr");
|
||||
const descCol = resolveColumn(headers, "vehicle_desc");
|
||||
const odoCol = resolveColumn(headers, "odo");
|
||||
const litresCol = resolveColumn(headers, "litres");
|
||||
const siteCol = resolveColumn(headers, "site");
|
||||
const consumptionCol = resolveColumn(headers, "consumption");
|
||||
|
||||
// Required at minimum: a date, an odometer/hour reading, and a litre
|
||||
// count. Vehicle can come from QR OR description (legacy).
|
||||
if (!dateCol || !odoCol || !litresCol) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Missing required columns",
|
||||
details: {
|
||||
need: ["Date Logged", "Km's/ Hours", "Litres Pumped"],
|
||||
found: headers,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!qrCol && !descCol) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Missing vehicle identifier column",
|
||||
details: {
|
||||
need: ["Vehicle QR", "Vehicle"],
|
||||
found: headers,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const pb = await pbAdmin();
|
||||
|
||||
// Create the batch with the original file attached. Fuel-logs stay
|
||||
// synchronous (fixed-format CSV, no AI needed), so the batch is born
|
||||
// `processed` — see Task 8 of the 2026-08-19 PO-import-queue plan.
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Audit-trail batch - original file attached so the raw sheet is
|
||||
// recoverable later even if the import mapping changes again.
|
||||
const batch = await pb.collection("data_import_batches").create({
|
||||
batch_type: "fuel_logs",
|
||||
date_uploaded: now,
|
||||
|
|
@ -64,55 +158,114 @@ export async function POST(request: Request) {
|
|||
attempts: 0,
|
||||
});
|
||||
|
||||
// Build description → vehicle lookup in one round-trip
|
||||
// Vehicle lookup. QR is the clean identifier (source data has QR
|
||||
// but the description column has typos and casing drift). Description
|
||||
// remains as a fallback for legacy CSV imports that lack a QR column.
|
||||
const allVehicles = await pb.collection("vehicles").getFullList({
|
||||
fields: "id,description,project_code",
|
||||
fields: "id,description,project_code,qr_code_id",
|
||||
});
|
||||
const vehicleByQr = new Map<string, { id: string; projectCode: string }>();
|
||||
const vehicleByDesc = new Map<string, { id: string; projectCode: string }>();
|
||||
for (const v of allVehicles) {
|
||||
vehicleByDesc.set(v.description.trim().toLowerCase(), {
|
||||
id: v.id,
|
||||
projectCode: v.project_code,
|
||||
});
|
||||
const qr = (v.qr_code_id ?? "").trim();
|
||||
if (qr) vehicleByQr.set(qr, { id: v.id, projectCode: v.project_code });
|
||||
const desc = (v.description ?? "").trim().toLowerCase();
|
||||
if (desc) vehicleByDesc.set(desc, { id: v.id, projectCode: v.project_code });
|
||||
}
|
||||
|
||||
// Site lookup by `sites.name`. One round-trip; cached for the whole
|
||||
// import. Used both to resolve the relation and to derive the
|
||||
// canonical `project_code` for the row.
|
||||
const allSites = await pb.collection("sites").getFullList({
|
||||
fields: "id,name,project_code",
|
||||
});
|
||||
const siteByName = new Map<string, { id: string; projectCode: string }>();
|
||||
for (const s of allSites) {
|
||||
const name = (s.name ?? "").trim().toLowerCase();
|
||||
if (name) siteByName.set(name, { id: s.id, projectCode: s.project_code });
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let qrMatched = 0;
|
||||
let descMatched = 0;
|
||||
const errors: { row: number; reason: string }[] = [];
|
||||
|
||||
for (let i = 0; i < parsed.data.length; i++) {
|
||||
const row = parsed.data[i];
|
||||
const desc = (row.vehicle_description ?? "").trim().toLowerCase();
|
||||
const date = (row.date_logged ?? "").trim();
|
||||
const litres = Number(row.litres);
|
||||
const odo = Number(row.odo_reading);
|
||||
for (let i = 0; i < rowsRaw.length; i++) {
|
||||
const row = rowsRaw[i];
|
||||
|
||||
if (!desc || !date || !Number.isFinite(litres) || !Number.isFinite(odo)) {
|
||||
// --- Required fields -----------------------------------------------
|
||||
const isoDate = parseDate(row[dateCol]);
|
||||
if (!isoDate) {
|
||||
errors.push({
|
||||
row: i + 2,
|
||||
reason: `Missing/invalid fields (need vehicle_description, date_logged, litres, odo_reading)`,
|
||||
reason: `Invalid/missing date (got ${JSON.stringify(row[dateCol])})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const vehicle = vehicleByDesc.get(desc);
|
||||
|
||||
const odo = toNumber(row[odoCol]);
|
||||
const litres = toNumber(row[litresCol]);
|
||||
if (odo === null || litres === null) {
|
||||
errors.push({
|
||||
row: i + 2,
|
||||
reason: "Missing/invalid odo_reading or litres (need both)",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Vehicle: QR first, description fallback -----------------------
|
||||
const qrRaw = qrCol ? String(row[qrCol] ?? "").trim() : "";
|
||||
const descRaw = descCol ? String(row[descCol] ?? "").trim() : "";
|
||||
let vehicle: { id: string; projectCode: string } | undefined;
|
||||
if (qrRaw && vehicleByQr.has(qrRaw)) {
|
||||
vehicle = vehicleByQr.get(qrRaw);
|
||||
qrMatched++;
|
||||
} else if (descRaw && vehicleByDesc.has(descRaw.toLowerCase())) {
|
||||
vehicle = vehicleByDesc.get(descRaw.toLowerCase());
|
||||
descMatched++;
|
||||
}
|
||||
if (!vehicle) {
|
||||
errors.push({
|
||||
row: i + 2,
|
||||
reason: `Unknown vehicle "${desc}"`,
|
||||
reason: `Unknown vehicle (qr=${qrRaw || "-"}, description=${descRaw || "-"})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const explicitProject = (row.project_code ?? "").trim();
|
||||
const projectCode = explicitProject || vehicle.projectCode;
|
||||
// --- Site (optional in the row) ------------------------------------
|
||||
let siteId: string | undefined;
|
||||
let siteProjectCode: string | undefined;
|
||||
const siteRaw = siteCol ? String(row[siteCol] ?? "").trim() : "";
|
||||
if (siteRaw) {
|
||||
const site = siteByName.get(siteRaw.toLowerCase());
|
||||
if (site) {
|
||||
siteId = site.id;
|
||||
siteProjectCode = site.projectCode;
|
||||
}
|
||||
}
|
||||
|
||||
// project_code: prefer the resolved site's project_code, then fall
|
||||
// back to the vehicle's project_code (matches pre-existing behaviour
|
||||
// for legacy CSV imports that have no site column).
|
||||
const projectCode = siteProjectCode ?? vehicle.projectCode;
|
||||
|
||||
// latest_consumption is OPTIONAL - empty cells stay undefined rather
|
||||
// than being silently set to 0.
|
||||
const consumptionRaw = consumptionCol ? row[consumptionCol] : undefined;
|
||||
const latestConsumption = toNumber(consumptionRaw);
|
||||
const latestConsumptionOrUndef =
|
||||
latestConsumption === null ? undefined : latestConsumption;
|
||||
|
||||
try {
|
||||
await pb.collection("fuel_logs").create({
|
||||
project_code: projectCode,
|
||||
site: siteId,
|
||||
vehicle: vehicle.id,
|
||||
batch_reference: batch.id,
|
||||
date_logged: new Date(date).toISOString(),
|
||||
date_logged: isoDate,
|
||||
litres,
|
||||
odo_reading: odo,
|
||||
latest_consumption: latestConsumptionOrUndef,
|
||||
});
|
||||
imported++;
|
||||
} catch (err) {
|
||||
|
|
@ -123,11 +276,21 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log("[fuel-import] summary", {
|
||||
totalRows: rowsRaw.length,
|
||||
imported,
|
||||
skipped: errors.length,
|
||||
qrMatched,
|
||||
descMatched,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
batchId: batch.id,
|
||||
totalRows: parsed.data.length,
|
||||
totalRows: rowsRaw.length,
|
||||
imported,
|
||||
skipped: errors.length,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ export interface FuelRow {
|
|||
variancePct: number | null;
|
||||
status: FuelStatus;
|
||||
cost: number;
|
||||
/**
|
||||
* "Latest Consumption" as reported by the source pump sheet (typically
|
||||
* litres/100km or litres/hour). Stored verbatim on the FUEL_LOGS row
|
||||
* so we can audit it against the app's recomputed variancePct later.
|
||||
* undefined when the source row had no value.
|
||||
*/
|
||||
latestConsumption?: number | null;
|
||||
}
|
||||
|
||||
export interface VehicleSummary {
|
||||
|
|
@ -376,6 +383,12 @@ export function FuelView({
|
|||
<TableHead>Vehicle</TableHead>
|
||||
<TableHead className="text-right">Odo / meter</TableHead>
|
||||
<TableHead className="text-right">Litres</TableHead>
|
||||
<TableHead
|
||||
className="text-right text-xs font-normal text-muted-foreground"
|
||||
title="Latest Consumption as recorded on the source pump sheet (L/100km for vehicles, L/h for plant)"
|
||||
>
|
||||
Consumption <span className="text-[10px]">(src)</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Allowable limit</TableHead>
|
||||
<TableHead>Variance</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
|
|
@ -384,7 +397,7 @@ export function FuelView({
|
|||
<TableBody>
|
||||
{pageRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="py-10 text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={10} className="py-10 text-center text-sm text-muted-foreground">
|
||||
{rows.length === 0
|
||||
? "No fuel logs yet — upload a CSV to get started."
|
||||
: "No fuel logs match the current filters."}
|
||||
|
|
@ -421,10 +434,24 @@ export function FuelView({
|
|||
<TableCell className="text-right tabular-nums">
|
||||
{formatNumber(r.litres, { maximumFractionDigits: 2 })} L
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
{r.latestConsumption != null ? (
|
||||
<span
|
||||
title="Latest Consumption as recorded on the source pump sheet (L/100km or L/h)"
|
||||
className="cursor-help border-b border-dotted border-muted-foreground/40"
|
||||
>
|
||||
{formatNumber(r.latestConsumption, {
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden>—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
{r.limitTarget !== null && r.limitUnit
|
||||
? `${r.limitTarget} ${r.limitUnit}`
|
||||
: "—"}
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={r.status} variancePct={r.variancePct} />
|
||||
|
|
@ -561,4 +588,4 @@ function FleetStatusBadge({
|
|||
{alert > 0 ? `${alert} alert` : `${warn} warn`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,6 +94,10 @@ export default async function FuelPage() {
|
|||
variancePct: variance,
|
||||
status,
|
||||
cost: log.litres * RATE_PER_LITRE,
|
||||
latestConsumption:
|
||||
"latest_consumption" in log
|
||||
? ((log as { latest_consumption?: number }).latest_consumption ?? null)
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -247,4 +251,4 @@ function computeVariance(
|
|||
}
|
||||
|
||||
return { variance: null, status: "n/a", limitTarget: limit.target, limitUnit: limit.unit };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,20 @@ interface ImportResult {
|
|||
errors: { row: number; reason: string }[];
|
||||
}
|
||||
|
||||
// Column expectations for the Aug 2026 pump-sheet shape (primary).
|
||||
// Legacy CSV with vehicle_description still works — the server route
|
||||
// falls back to description-matching if no Vehicle QR column exists.
|
||||
const SAMPLE_HEADERS = [
|
||||
"vehicle_description",
|
||||
"date_logged",
|
||||
"vehicle_qr",
|
||||
"vehicle",
|
||||
"litres",
|
||||
"odo_reading",
|
||||
"current_site",
|
||||
];
|
||||
|
||||
const SAMPLE_OPTIONAL = [
|
||||
"latest_consumption",
|
||||
];
|
||||
|
||||
export function ImportFuelLogsButton() {
|
||||
|
|
@ -103,8 +112,8 @@ export function ImportFuelLogsButton() {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Import fuel logs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a CSV — each row creates one fuel-log entry linked to the
|
||||
source file for audit.
|
||||
Upload an Excel (.xlsx) or CSV file - each row creates one fuel-log
|
||||
entry linked to the source file for audit.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -125,8 +134,21 @@ export function ImportFuelLogsButton() {
|
|||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Optional: <span className="font-mono">project_code</span> —
|
||||
falls back to the vehicle's project.
|
||||
Optional:{" "}
|
||||
{SAMPLE_OPTIONAL.map((h, i) => (
|
||||
<span key={h}>
|
||||
{i > 0 ? ", " : ""}
|
||||
<span className="font-mono">{h}</span>
|
||||
</span>
|
||||
))}{" "}
|
||||
- latest_consumption is persisted verbatim (empty cells stay
|
||||
blank). Vehicles are matched by QR first; the route falls
|
||||
back to description-matching for legacy CSVs that lack a
|
||||
Vehicle QR column.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Dates: DD/MM/YYYY (e.g. <span className="font-mono">24/08/2026</span>).
|
||||
Legacy YYYY-MM-DD also accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -135,7 +157,7 @@ export function ImportFuelLogsButton() {
|
|||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
accept=".csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"
|
||||
className="sr-only"
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
|
|
@ -144,7 +166,7 @@ export function ImportFuelLogsButton() {
|
|||
<FileSpreadsheet className="h-7 w-7 text-[#F2B165]" />
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)} KB ·{" "}
|
||||
{(file.size / 1024).toFixed(1)} KB -{" "}
|
||||
{format(new Date(file.lastModified), "yyyy-MM-dd HH:mm")}
|
||||
</p>
|
||||
<button
|
||||
|
|
@ -161,7 +183,9 @@ export function ImportFuelLogsButton() {
|
|||
) : (
|
||||
<>
|
||||
<Upload className="h-7 w-7 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Click to choose a CSV</p>
|
||||
<p className="text-sm font-medium">
|
||||
Click to choose an Excel or CSV file
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Daily / weekly fuel-sheet export from the pump
|
||||
</p>
|
||||
|
|
@ -187,7 +211,7 @@ export function ImportFuelLogsButton() {
|
|||
{phase === "uploading" ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Uploading…
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -230,7 +254,7 @@ function ResultSummary({
|
|||
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
Batch <span className="font-mono text-foreground">{result.batchId}</span>{" "}
|
||||
created and the CSV is attached for the audit trail.
|
||||
created and the source file is attached for the audit trail.
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
|
|
@ -282,4 +306,4 @@ function SummaryStat({
|
|||
<p className="mt-0.5 text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
src/lib/fuel-import-parse.test.ts
Normal file
123
src/lib/fuel-import-parse.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { normHeader, resolveColumn, parseDate, toNumber } from "./fuel-import-parse";
|
||||
|
||||
describe("normHeader", () => {
|
||||
it("lowercases and trims", () => {
|
||||
expect(normHeader(" Date Logged ")).toBe("date logged");
|
||||
});
|
||||
it("collapses internal whitespace runs", () => {
|
||||
expect(normHeader("Km's/ Hours")).toBe("km's/ hours");
|
||||
});
|
||||
it("returns empty string for non-strings", () => {
|
||||
expect(normHeader(undefined)).toBe("");
|
||||
expect(normHeader(null)).toBe("");
|
||||
expect(normHeader(42)).toBe("");
|
||||
expect(normHeader({})).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveColumn", () => {
|
||||
it("matches the primary header verbatim", () => {
|
||||
expect(
|
||||
resolveColumn(["Date Logged", "Vehicle QR", "Litres Pumped"], "date"),
|
||||
).toBe("Date Logged");
|
||||
});
|
||||
it("matches case-insensitively and tolerates whitespace", () => {
|
||||
expect(
|
||||
resolveColumn(["date logged", "VEHICLE QR", "Litres Pumped"], "date"),
|
||||
).toBe("date logged");
|
||||
expect(
|
||||
resolveColumn(["date logged", "VEHICLE QR", "Litres Pumped"], "vehicle_qr"),
|
||||
).toBe("VEHICLE QR");
|
||||
});
|
||||
it("falls back to legacy aliases", () => {
|
||||
expect(
|
||||
resolveColumn(["date_logged", "qr_code_id", "litres"], "vehicle_qr"),
|
||||
).toBe("qr_code_id");
|
||||
expect(
|
||||
resolveColumn(["date_logged", "vehicle_description", "litres"], "vehicle_desc"),
|
||||
).toBe("vehicle_description");
|
||||
expect(
|
||||
resolveColumn(["date_logged", "odo_reading", "litres"], "odo"),
|
||||
).toBe("odo_reading");
|
||||
});
|
||||
it("returns null when nothing matches", () => {
|
||||
expect(
|
||||
resolveColumn(["Foo", "Bar", "Baz"], "consumption"),
|
||||
).toBeNull();
|
||||
});
|
||||
it("returns the exact original header text (preserves case)", () => {
|
||||
// The route uses the resolved value as a key into the row object,
|
||||
// so it must be exactly the header the spreadsheet provided.
|
||||
const hdrs = ["Date Logged", "Vehicle QR", "Litres Pumped"];
|
||||
expect(resolveColumn(hdrs, "litres")).toBe("Litres Pumped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDate", () => {
|
||||
it("parses DD/MM/YYYY (the Aug 2026 Hills sheet format)", () => {
|
||||
expect(parseDate("24/08/2026")).toBe("2026-08-24");
|
||||
expect(parseDate("3/8/2026")).toBe("2026-08-03");
|
||||
});
|
||||
it("handles leading whitespace (present in the source sheet)", () => {
|
||||
expect(parseDate(" 24/08/2026")).toBe("2026-08-24");
|
||||
expect(parseDate(" 04/08/2026 ")).toBe("2026-08-04");
|
||||
});
|
||||
it("rejects impossible dates", () => {
|
||||
expect(parseDate("32/13/2026")).toBeNull();
|
||||
expect(parseDate("13/32/2026")).toBeNull(); // DD/MM/YYYY rejects day>31
|
||||
expect(parseDate("00/08/2026")).toBeNull(); // day=0 invalid
|
||||
});
|
||||
it("parses YYYY-MM-DD (legacy CSV)", () => {
|
||||
expect(parseDate("2026-08-24")).toBe("2026-08-24");
|
||||
});
|
||||
it("parses Excel date serials (raw:true output from SheetJS)", () => {
|
||||
// 2026-08-24 in Excel serial = (Date.UTC(2026,7,24)/86400000) + 25569
|
||||
const serial = Math.floor((Date.UTC(2026, 7, 24) / 86400000) + 25569);
|
||||
expect(parseDate(serial)).toBe("2026-08-24");
|
||||
});
|
||||
it("returns null for empty / non-string non-numeric input", () => {
|
||||
expect(parseDate("")).toBeNull();
|
||||
expect(parseDate(" ")).toBeNull();
|
||||
expect(parseDate(null)).toBeNull();
|
||||
expect(parseDate(undefined)).toBeNull();
|
||||
expect(parseDate("not a date")).toBeNull();
|
||||
expect(parseDate({})).toBeNull();
|
||||
});
|
||||
it("returns null for numbers below the Excel serial threshold", () => {
|
||||
// Avoids accidentally treating small integers (e.g. row indices) as
|
||||
// dates.
|
||||
expect(parseDate(0)).toBeNull();
|
||||
expect(parseDate(25569)).toBeNull(); // exactly the threshold; we use > not >=
|
||||
});
|
||||
});
|
||||
|
||||
describe("toNumber", () => {
|
||||
it("parses numeric strings", () => {
|
||||
expect(toNumber("42")).toBe(42);
|
||||
expect(toNumber("42.5")).toBe(42.5);
|
||||
expect(toNumber(" 42 ")).toBe(42);
|
||||
});
|
||||
it("passes through finite numbers", () => {
|
||||
expect(toNumber(42)).toBe(42);
|
||||
expect(toNumber(42.5)).toBe(42.5);
|
||||
expect(toNumber(0)).toBe(0);
|
||||
});
|
||||
it("returns null for empty / whitespace", () => {
|
||||
expect(toNumber("")).toBeNull();
|
||||
expect(toNumber(" ")).toBeNull();
|
||||
expect(toNumber(null)).toBeNull();
|
||||
expect(toNumber(undefined)).toBeNull();
|
||||
});
|
||||
it("returns null for NaN and Infinity", () => {
|
||||
expect(toNumber(NaN)).toBeNull();
|
||||
expect(toNumber(Infinity)).toBeNull();
|
||||
expect(toNumber(-Infinity)).toBeNull();
|
||||
expect(toNumber("NaN")).toBeNull();
|
||||
expect(toNumber("Infinity")).toBeNull();
|
||||
});
|
||||
it("returns null for unparseable strings", () => {
|
||||
expect(toNumber("foo")).toBeNull();
|
||||
expect(toNumber("42abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
100
src/lib/fuel-import-parse.ts
Normal file
100
src/lib/fuel-import-parse.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Pure parsing helpers for the fuel-logs import route. Extracted from
|
||||
* the route so they can be unit-tested without spinning up PocketBase
|
||||
* or the Next.js request lifecycle.
|
||||
*
|
||||
* All four helpers are stateless and side-effect free.
|
||||
*/
|
||||
|
||||
// Aliases - order doesn't matter (we do case-insensitive equality),
|
||||
// but each list is documented by its primary header.
|
||||
export const 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.
|
||||
* 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 COL_ALIASES,
|
||||
): string | null {
|
||||
const target = COL_ALIASES[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 format: `DD/MM/YYYY` (South African pump sheets). Also
|
||||
* handles a leading space. Falls back to `YYYY-MM-DD` for legacy CSV,
|
||||
* and to Excel date serials (numbers > 25569).
|
||||
* Returns null if none of those formats match.
|
||||
*/
|
||||
export function parseDate(input: unknown): string | null {
|
||||
if (input === null || input === undefined) return null;
|
||||
// Excel serial number (SheetJS with raw:true can return these for
|
||||
// date-formatted cells). 25569 = 1970-01-01 in Excel's epoch.
|
||||
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;
|
||||
// DD/MM/YYYY (tolerates D/M/YYYY)
|
||||
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;
|
||||
}
|
||||
// YYYY-MM-DD (legacy CSV)
|
||||
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.
|
||||
* Empty cells (Excel blank, "") return null - the caller decides whether
|
||||
* that's an error (required fields) or "leave undefined" (optional).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
|
@ -138,6 +138,15 @@ export interface FuelLogRecord extends RecordModel {
|
|||
date_logged: string;
|
||||
litres: number;
|
||||
odo_reading: number;
|
||||
/**
|
||||
* "Latest Consumption" value as reported by the pump sheet (typically
|
||||
* litres/100km or litres/hour). Persisted verbatim from the source
|
||||
* Excel/CSV rather than recomputed, so the audit trail reflects what
|
||||
* the fuel team actually filed. Optional — the Aug 2026 sample has
|
||||
* ~9% empty cells, which we preserve as `undefined` rather than
|
||||
* fabricating a value. Added in the 2026-08-24 migration.
|
||||
*/
|
||||
latest_consumption?: number;
|
||||
expand?: {
|
||||
site?: SiteRecord;
|
||||
vehicle?: VehicleRecord;
|
||||
|
|
|
|||
Loading…
Reference in a new issue