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.
131 lines
4.4 KiB
Python
Executable file
131 lines
4.4 KiB
Python
Executable file
#!/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)
|