From 1429c8087b637fc10f10c19e70a94a1fc67590b7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 00:18:24 +0200 Subject: [PATCH] feat(plant): vehicle site history from Fuel Logs /plant and /plant/map import Fuel Logs CSVs by exact QR and show each vehicle's current and previous distinct site with the dates they were observed. - plant_site_observations is durable, QR-keyed history. It is never wiped and a previous site is never manufactured. - An older or repeated snapshot must not move a vehicle backwards; untimed conflicting sites on one day are held for review. - A manual site change shares the same history and the same lock. - Current/previous metadata and the sticker QR are captured BEFORE the asset rows are deleted in the seed, so a re-seed cannot wipe them. Explicit empty values are preserved too: clearing a site is a deliberate manual observation, not permission to restore an old sheet value. - Imports change location metadata only. They never touch income, cost or fuel measurements. - A new named site is created only for a matched Plant vehicle. Missing coordinates mean "awaiting a pin", never a headquarters fallback. Owner-confirmed 2026-11-04: TLB06's QR is 1089, not the old meter reading 4704. lib/plant-site-policy.json holds the confirmed aliases (Equestria Bastion Job -> Equestria 215, Cosmo City / Lion Park -> Cosmo City); no other identity is guessed. --- app/(dashboard)/plant/map/page.tsx | 11 +- app/(dashboard)/plant/page.tsx | 10 +- app/api/assets/[id]/route.ts | 8 +- app/api/plant/site-import/route.ts | 18 ++ components/FuelSiteImport.tsx | 94 +++++++++ components/PlantMap.tsx | 54 ++++-- components/PlantRegisterTable.tsx | 44 +++-- components/VehicleSiteHistory.tsx | 28 +++ data/source/assets.json | 8 + docs/plant-site-import.md | 85 ++++++++ .../2026-11-04-plant-fuel-site-history.md | 25 +++ lib/data.ts | 7 + lib/plant-map.ts | 6 +- lib/plant-site-history.test.ts | 63 ++++++ lib/plant-site-history.ts | 88 +++++++++ lib/plant-site-import.test.ts | 181 ++++++++++++++++++ lib/plant-site-import.ts | 150 +++++++++++++++ lib/plant-site-policy.json | 7 + scripts/import-plant-sites.ts | 26 +++ scripts/seed.mjs | 56 +++--- scripts/setup-plant-site-history.mjs | 25 +++ 21 files changed, 924 insertions(+), 70 deletions(-) create mode 100644 app/api/plant/site-import/route.ts create mode 100644 components/FuelSiteImport.tsx create mode 100644 components/VehicleSiteHistory.tsx create mode 100644 docs/plant-site-import.md create mode 100644 docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md create mode 100644 lib/plant-site-history.test.ts create mode 100644 lib/plant-site-history.ts create mode 100644 lib/plant-site-import.test.ts create mode 100644 lib/plant-site-import.ts create mode 100644 lib/plant-site-policy.json create mode 100644 scripts/import-plant-sites.ts create mode 100644 scripts/setup-plant-site-history.mjs diff --git a/app/(dashboard)/plant/map/page.tsx b/app/(dashboard)/plant/map/page.tsx index d44302d..90e5059 100644 --- a/app/(dashboard)/plant/map/page.tsx +++ b/app/(dashboard)/plant/map/page.tsx @@ -1,3 +1,4 @@ +import { FuelSiteImport } from "@/components/FuelSiteImport"; import { getAssets, getSites } from "@/lib/server-data"; import { hasCoords, resolveAssetLocation } from "@/lib/plant-map"; import { PlantMapLoader } from "@/components/PlantMapLoader"; @@ -11,7 +12,7 @@ export default async function PlantMapPage() { const located = assets.filter(a => hasCoords(resolveAssetLocation(a, sites))).length; return ( -
+
@@ -26,6 +27,7 @@ export default async function PlantMapPage() {
+ ({ id: a.id, @@ -35,6 +37,13 @@ export default async function PlantMapPage() { type: a.type, is_active: a.is_active !== false, site: a.site, + site_label: a.site_label || "", + previous_site: a.previous_site || "", + previous_site_label: a.previous_site_label || "", + site_observed_on: a.site_observed_on || "", + previous_site_observed_on: a.previous_site_observed_on || "", + site_source: a.site_source || "", + qr_code: a.qr_code || "", latitude: a.latitude, longitude: a.longitude, }))} diff --git a/app/(dashboard)/plant/page.tsx b/app/(dashboard)/plant/page.tsx index 04adcdf..10237d0 100644 --- a/app/(dashboard)/plant/page.tsx +++ b/app/(dashboard)/plant/page.tsx @@ -1,3 +1,4 @@ +import { FuelSiteImport } from "@/components/FuelSiteImport"; import { getAssets, getSites } from "@/lib/server-data"; import { PlantRegisterTable } from "@/components/PlantRegisterTable"; @@ -9,7 +10,7 @@ export const dynamic = "force-dynamic"; export default async function PlantPage() { const [assets, sites] = await Promise.all([getAssets(), getSites()]); return ( -
+
@@ -28,6 +29,7 @@ export default async function PlantPage() { supplier / monthly_excl / monthly_incl / purchase_value stay in the PocketBase `assets` collection — other pages (Income, Running Costs, Expenses) read them — they're just not part of the register view. */} + ({ id: s.id, name: s.name, code: s.code, is_active: s.is_active !== false }))} assets={assets.map((a) => ({ @@ -39,6 +41,12 @@ export default async function PlantPage() { is_provisional: !!(a as any).is_provisional, is_active: (a as any).is_active !== false, site: a.site || "", + site_label: a.site_label || "", + previous_site: a.previous_site || "", + previous_site_label: a.previous_site_label || "", + site_observed_on: a.site_observed_on || "", + previous_site_observed_on: a.previous_site_observed_on || "", + site_source: a.site_source || "", qr_code: (a as any).qr_code || "", }))} /> diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index 440f1f9..4c78c39 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -1,6 +1,7 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; import { cascadeImpact, revalidateAssetSurfaces } from "./impact/shared"; +import { updateAssetWithSiteHistory } from "@/lib/plant-site-import"; export const dynamic = "force-dynamic"; @@ -160,13 +161,16 @@ export async function PATCH( try { const pb = await serverAuth(); - const updated = await pb.collection("assets").update(id, patch); + const updated = "site" in patch + ? await updateAssetWithSiteHistory(pb, id, patch) + : await pb.collection("assets").update(id, patch); // Plant Register, the Plant Map, and the dashboard's GlobalSearch all read // the asset list, so refresh every one. revalidatePath("/plant") does not // cover "/plant/map" — sub-paths must be named separately. revalidatePath("/plant"); revalidatePath("/plant/map"); revalidatePath("/"); + revalidatePath("/sites"); return Response.json({ ok: true, asset: updated }); } catch (e: any) { return Response.json( @@ -205,4 +209,4 @@ export async function DELETE( { status: 500 }, ); } -} \ No newline at end of file +} diff --git a/app/api/plant/site-import/route.ts b/app/api/plant/site-import/route.ts new file mode 100644 index 0000000..d8c1d20 --- /dev/null +++ b/app/api/plant/site-import/route.ts @@ -0,0 +1,18 @@ +import {revalidatePath} from 'next/cache'; +import {requireUser} from '@/lib/auth'; +import {serverAuth} from '@/lib/pb'; +import {applyPlantSites,previewPlantSites} from '@/lib/plant-site-import'; +export const dynamic='force-dynamic'; +export async function POST(request:Request){ + await requireUser(); + try{ + const form=await request.formData(),file=form.get('file'),mode=form.get('mode'); + if(!(file instanceof File)||!file.size||!file.name.toLowerCase().endsWith('.csv'))return Response.json({ok:false,error:'Choose a Fuel Logs CSV.'},{status:400}); + if(file.size>5*1024*1024)return Response.json({ok:false,error:'The CSV is too large (5 MB max).'},{status:413}); + if(mode!=='preview'&&mode!=='apply')return Response.json({ok:false,error:'Choose preview or apply.'},{status:400}); + const pb=await serverAuth(),text=await file.text(); + const result=mode==='preview'?await previewPlantSites(pb,text,file.name):await applyPlantSites(pb,text,file.name,String(form.get('fileHash')||'')); + if(mode==='apply'){for(const route of ['/plant','/plant/map','/sites','/'])revalidatePath(route);revalidatePath('/sites/[id]','page');} + return Response.json(result); + }catch(error){const message=(error as Error).message;return Response.json({ok:false,error:message},{status:/Another site update/.test(message)?409:400});} +} diff --git a/components/FuelSiteImport.tsx b/components/FuelSiteImport.tsx new file mode 100644 index 0000000..77ceaae --- /dev/null +++ b/components/FuelSiteImport.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useId, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Upload, X } from "lucide-react"; +import { siteSeenDate } from "./VehicleSiteHistory"; + +type Change = { + assetId: string; code: string; qr: string; currentSite: string; previousSite: string; + lastSeen: string; previousSeen: string; status: string; needsCoordinates: boolean; +}; +type Preview = { + ok: boolean; fileHash: string; totalRows: number; matchedVehicles: number; observationCount: number; + ignoredRows: number; pendingObservations?: number; unknownQrs: { qr: string; name: string; rows: number }[]; + issues: { qr: string; reason: string }[]; changes: Change[]; newSites: string[]; canApply: boolean; + appliedVehicles?: number; insertedObservations?: number; unchangedVehicles?: number; heldVehicles?: number; +}; +const control = "inline-flex min-h-[44px] items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium disabled:opacity-40"; + +export function FuelSiteImport() { + const router = useRouter(); + const inputId = useId(); + const fileInput = useRef(null); + const [open, setOpen] = useState(false); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState<"preview" | "apply" | null>(null); + const [error, setError] = useState(""); + const [result, setResult] = useState(""); + + function reset() { + setFile(null); setPreview(null); setError(""); + if (fileInput.current) fileInput.current.value = ""; + } + + async function submit(mode: "preview" | "apply") { + if (!file || busy || (mode === "apply" && !preview?.canApply)) return; + setBusy(mode); setError(""); setResult(""); + try { + const form = new FormData(); + form.set("file", file); form.set("mode", mode); + if (mode === "apply") form.set("fileHash", preview!.fileHash); + const response = await fetch("/api/plant/site-import", { method: "POST", body: form }); + const data = await response.json(); + if (!response.ok || !data.ok) throw new Error(data.error || "Could not read the fuel log"); + if (mode === "preview") setPreview(data); + else { + setResult(`Fuel log imported: ${data.appliedVehicles ?? 0} vehicles updated, ${data.unchangedVehicles ?? 0} unchanged, ${data.heldVehicles ?? 0} held for review. ${data.insertedObservations ?? 0} observations saved.`); + reset(); setOpen(false); router.refresh(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Could not reach the server"); + if (mode === "preview") setPreview(null); + } finally { setBusy(null); } + } + + return ( +
+ + {result &&

{result}

} + {open &&
+
+

Update vehicle sites from a fuel log

Choose the exported CSV, review the latest logged site for each matched QR, then apply. Previous site means the last different site recorded.

+ +
+
+
{ setFile(e.target.files?.[0] ?? null); setPreview(null); setError(""); }} className="w-full min-w-0 min-h-[44px] rounded-lg border border-gray-200 p-2 text-sm file:mr-2 file:rounded file:border-0 file:bg-gray-100 file:px-2 file:py-1 file:text-xs dark:border-white/10 dark:file:bg-white/10 dark:file:text-white" />
+ +
+ {error &&

{error}

} + {preview &&
+

{preview.totalRows} rows · {preview.matchedVehicles} matched vehicles · {preview.observationCount} observations · {preview.ignoredRows} ignored rows

+ {preview.newSites.length > 0 &&

New sites: {preview.newSites.join(", ")}. Add their coordinates in the map's site panel after import.

} + {(preview.unknownQrs.length > 0 || preview.issues.length > 0) &&
+ {preview.unknownQrs.length > 0 &&
{preview.unknownQrs.length} unmatched QR codes — excluded
    {preview.unknownQrs.map((q, i) =>
  • {q.qr || "Empty QR"}{q.name ? ` · ${q.name}` : ""} · {q.rows} rows
  • )}
} + {preview.issues.length > 0 &&
{preview.issues.length} issues to review
    {preview.issues.map((issue, i) =>
  • {issue.qr && `${issue.qr}: `}{issue.reason}
  • )}
} +
} +
+ {preview.changes.map(change =>
+
{change.code || change.qr}{change.status.replaceAll("-", " ").replaceAll("_", " ")}
+
Current site
{change.currentSite || "Unknown"}
Previous site
{change.previousSite || "Unknown"}
{change.previousSeen &&
{siteSeenDate(change.previousSeen)}
}
Last seen
{siteSeenDate(change.lastSeen)}
+ {change.needsCoordinates &&

Site coordinates needed to show this vehicle on the map.

} +
)} + {preview.changes.length === 0 &&

No matched vehicles to update.

} +
+
+ {!preview.canApply &&

{preview.changes.length > 0 && preview.issues.length === 0 ? "This fuel log is already up to date. No new observations or site changes to apply." : "No updates can be applied from this file. Review unmatched QR codes and any issues above."}

} +
} +
} +
+ ); +} diff --git a/components/PlantMap.tsx b/components/PlantMap.tsx index c45fdf0..bc43bb1 100644 --- a/components/PlantMap.tsx +++ b/components/PlantMap.tsx @@ -8,6 +8,8 @@ import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; import MarkerClusterGroup from "react-leaflet-cluster"; import L from "leaflet"; import { MapPinned, Loader2, Search, X, MapPin, Trash2 } from "lucide-react"; +import { VehicleSiteHistory } from "./VehicleSiteHistory"; +import { SiteCoordinateEditor } from "./SiteCoordinateEditor"; import { cn } from "@/lib/utils"; import { HEAD_OFFICE, hasCoords, resolveAssetLocation, type MapAsset, type MapSite } from "@/lib/plant-map"; @@ -89,12 +91,19 @@ function waypointIcon(office = false) { }); } -function MapController({ focus, points, fitKey }: { +function MapController({ focus, points, fitKey, onPopupChange }: { focus: { lat: number; lng: number; zoom?: number } | null; points: [number, number][]; fitKey: number; + onPopupChange: (open: boolean) => void; }) { const map = useMap(); + useEffect(() => { + const opened = () => onPopupChange(true); + const closed = () => onPopupChange(false); + map.on("popupopen", opened); map.on("popupclose", closed); + return () => { map.off("popupopen", opened); map.off("popupclose", closed); }; + }, [map, onPopupChange]); useEffect(() => { map.fitBounds(L.latLngBounds(points), { padding: [50, 70], maxZoom: 14 }); }, [map, points, fitKey]); @@ -111,6 +120,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite const [showInactive, setShowInactive] = useState(true); const [focus, setFocus] = useState<{ lat: number; lng: number; zoom?: number } | null>(null); const [fitKey, setFitKey] = useState(0); + const [popupOpen, setPopupOpen] = useState(false); const [placingId, setPlacingId] = useState(null); const [saving, setSaving] = useState(false); const [notice, setNotice] = useState(null); @@ -120,6 +130,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite const located = useMemo(() => mappedAssets.filter(hasCoords), [mappedAssets]); const awaiting = useMemo(() => mappedAssets.filter(a => !hasCoords(a)), [mappedAssets]); const pinnedSites = useMemo(() => sites.filter(hasCoords), [sites]); + const unpinnedSites = useMemo(() => sites.filter(site => !hasCoords(site)), [sites]); const points = useMemo<[number, number][]>(() => [ [HEAD_OFFICE.latitude, HEAD_OFFICE.longitude], ...pinnedSites.map(s => [s.latitude, s.longitude] as [number, number]), @@ -136,7 +147,8 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite a.code.toLowerCase().includes(q) || a.name.toLowerCase().includes(q) || a.type.toLowerCase().includes(q) || - a.siteName?.toLowerCase().includes(q), + a.siteName?.toLowerCase().includes(q) || + a.qr_code?.toLowerCase().includes(q), ); }, [mappedAssets, query, showInactive]); @@ -151,7 +163,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.error || "Could not save location"); setAssets((curr) => - curr.map((a) => (a.id === id ? { ...a, latitude: lat, longitude: lng } : a)), + curr.map((a) => (a.id === id ? { ...a, latitude: lat, longitude: lng, ...(data.asset || {}) } : a)), ); setPlacingId(null); setNotice("Location saved"); @@ -173,7 +185,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.error || "Could not clear location"); setAssets((curr) => - curr.map((a) => (a.id === id ? { ...a, latitude: null, longitude: null } : a)), + curr.map((a) => (a.id === id ? { ...a, latitude: null, longitude: null, ...(data.asset || {}) } : a)), ); setNotice("Location cleared"); } catch (e: any) { @@ -203,7 +215,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite className={cn("w-full", "h-[52vh] min-h-[320px]", "lg:h-[calc(100vh-15rem)] lg:min-h-[560px]")} > - + {placingId && saveCoords(placingId, lat, lng)} disabled={saving} />} Head office

Default location for unassigned machines.

@@ -262,6 +274,8 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite {a.siteName ? `${a.siteName} · Site location` : a.source === "head-office" ? "Head office · Default location" : "Manual location"}
{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
+
site.id === a.previous_site)?.name} />
+ {a.qr_code &&

QR: {a.qr_code}

}
@@ -269,10 +283,10 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite - + } {/* Legend */}
Site @@ -321,10 +335,16 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite {assetsState.filter(a => a.site === site.id).length} assigned ))} - {sites.length > pinnedSites.length && ( - - {sites.length - pinnedSites.length} sites still need coordinates - + {unpinnedSites.length > 0 && ( +
+ {unpinnedSites.length} sites need coordinates +
+ {unpinnedSites.map(site =>
+

{site.name}

+ +
)} +
+
)}

Assigned machines follow their site's pin. These locations are based on assignments, not live GPS.

@@ -399,11 +419,12 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
  • +
    )} +
    +
    + Vehicle site details +
    site.id === a.previous_site)?.name} /> +

    QR: {a.qr_code || a.code || "Not recorded"}

    +
    +
  • ); })} diff --git a/components/PlantRegisterTable.tsx b/components/PlantRegisterTable.tsx index e61ae89..9823a4e 100644 --- a/components/PlantRegisterTable.tsx +++ b/components/PlantRegisterTable.tsx @@ -3,6 +3,7 @@ import { useState, useMemo, useEffect } from "react"; import { QrTextCell } from "@/components/QrTextCell"; import { Search, ArrowUpDown, AlertTriangle, Pencil, Check, Power, Trash2, Loader2, X } from "lucide-react"; +import { VehicleSiteHistory } from "./VehicleSiteHistory"; import { ScrollFade } from "./ScrollFade"; import { cn } from "@/lib/utils"; @@ -22,6 +23,12 @@ type Asset = { site: string; /** Text on the vehicle's QR sticker; empty = the register code. */ qr_code?: string; + site_label?: string; + previous_site?: string; + previous_site_label?: string; + site_observed_on?: string; + previous_site_observed_on?: string; + site_source?: string; }; type Site = { @@ -56,6 +63,7 @@ export function PlantRegisterTable({ sites?: Site[]; }) { const [assets, setAssets] = useState(initialAssets); + useEffect(() => setAssets(initialAssets), [initialAssets]); const [query, setQuery] = useState(""); const [typeFilter, setTypeFilter] = useState("All"); const [siteFilter, setSiteFilter] = useState("All"); @@ -266,6 +274,7 @@ export function PlantRegisterTable({ }); const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.error || "Update failed"); + if (data.asset) setAssets(curr => curr.map(a => a.id === id ? { ...a, ...data.asset } : a)); } // Success — clear the draft buffer and leave edit mode. setDrafts({}); @@ -415,7 +424,7 @@ export function PlantRegisterTable({ a.name.toLowerCase().includes(q) || a.type.toLowerCase().includes(q) || (a.qr_code || "").toLowerCase().includes(q) || - siteName(effectiveSite(a)).toLowerCase().includes(q) + (siteName(effectiveSite(a)) || a.site_label || "").toLowerCase().includes(q) ); } list = [...list].sort((a, b) => { @@ -432,21 +441,21 @@ export function PlantRegisterTable({ const headerCell = (key: SortKey, label: string, align: "left" | "right" = "left") => ( { + aria-sort={sortKey === key ? (sortDir === "asc" ? "ascending" : "descending") : "none"} + className={`px-3 font-medium ${key === "code" ? "sticky left-0 z-20 bg-gray-50 dark:bg-surface" : ""}`} + > + ); return ( -
    +
    {/* Header / search / edit button */}
    @@ -455,8 +464,8 @@ export function PlantRegisterTable({ {filtered.length} of {assets.length} items · sorted by {sortKey} {sortDir}

    -
    -
    +
    +
    Edit ) : ( -
    +
    {/* Edit mode always offers a way out. Previously Discard was only rendered when something had changed and Save was disabled until then, so entering Edit and changing nothing left the @@ -616,7 +625,7 @@ export function PlantRegisterTable({ : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/5" )} > - {t} ({count}) + {t} ({count}) ); })} @@ -676,7 +685,7 @@ export function PlantRegisterTable({ {headerCell("code", "Code")} {headerCell("name", "Name")} {headerCell("type", "Type")} - {headerCell("site", "Site")} + {headerCell("site", "Site history")} QR code Status {editing && Delete} @@ -821,13 +830,10 @@ export function PlantRegisterTable({ ))} - ) : effectiveSite(a) ? ( - - - {siteName(effectiveSite(a)) || "Unknown site"} - ) : ( - — +
    + +
    )} diff --git a/components/VehicleSiteHistory.tsx b/components/VehicleSiteHistory.tsx new file mode 100644 index 0000000..7f4a2cd --- /dev/null +++ b/components/VehicleSiteHistory.tsx @@ -0,0 +1,28 @@ +type SiteHistory = { + site_label?: string; + previous_site_label?: string; + site_observed_on?: string; + previous_site_observed_on?: string; + site_source?: string; +}; + +export function siteSeenDate(value?: string) { + if (!value) return "Unknown"; + const date = new Date(value.length === 10 ? `${value}T12:00:00Z` : value); + return Number.isNaN(date.getTime()) ? "Unknown" : date.toLocaleDateString("en-ZA", { day: "numeric", month: "short", year: "numeric", timeZone: "Africa/Johannesburg" }); +} + +export function VehicleSiteHistory({ asset, currentSite, previousSite }: { + asset: SiteHistory; + currentSite?: string | null; + previousSite?: string; +}) { + const source = asset.site_source === "fuel-log" ? "Fuel log" : asset.site_source === "manual" ? "Manual assignment" : "Source not recorded"; + return ( +
    +
    Current site
    {asset.site_label || currentSite || "Unassigned"}
    +
    Previous site
    {asset.previous_site_label || previousSite || "Unknown — no earlier different site recorded"}{asset.previous_site_observed_on && {siteSeenDate(asset.previous_site_observed_on)}}
    +
    Last seen
    {siteSeenDate(asset.site_observed_on)} · {source}
    +
    + ); +} diff --git a/data/source/assets.json b/data/source/assets.json index b898f54..3d49158 100644 --- a/data/source/assets.json +++ b/data/source/assets.json @@ -340,5 +340,13 @@ "monthly_incl": 0, "type": "Solar Office", "is_provisional": true + }, + { + "name": "1500Lit Water trailer", + "code": "WT02", + "category": "Plant", + "monthly_incl": 0, + "type": "Water Trailer", + "is_provisional": false } ] diff --git a/docs/plant-site-import.md b/docs/plant-site-import.md new file mode 100644 index 0000000..0e5591f --- /dev/null +++ b/docs/plant-site-import.md @@ -0,0 +1,85 @@ +# Plant site history from Buildsite Fuel Logs + +Fuel Logs provide dated evidence of where an existing Plant vehicle was recorded. The importer updates its current site and shows the previous **distinct** recorded site. It does not import fuel measurements, create vehicles, infer trips, or change a vehicle's QR identity. + +## Initial setup + +Run from the Plant checkout after installing its dependencies and configuring its existing `.env.local` database settings: + +```sh +cd /Volumes/EXT/DEVELOPMENT/PLANT_TEST +node scripts/setup-plant-site-history.mjs +``` + +The setup script adds the site-history schema idempotently. The importer loads Plant's own environment file; credentials must not be passed on the command line, copied into reports, or added to this runbook. + +## Source and matching rules + +Use the download skill's complete `fuel-logs-all-sites.csv`, not a site-specific extract. Preserve the CSV beside its `manifest.json` and `source-records.json`. Before importing, verify that the manifest reports a complete source period, matching source/server counts and litres, and pagination ending with `at_end: true`. Report any unresolved labels or source warnings. The downloader reconstructs these files from authenticated Buildsite records; they are not a native Summary export. + +- A Buildsite vehicle QR must match an existing Plant vehicle exactly. A source submission ID identifies an observation; it is not a vehicle QR. Unmatched upstream vehicles are reported and intentionally ignored. Names, registrations and similar-looking codes are not fallback identity matches. +- The latest recorded observation date determines the current site. The logged date describes the observation; download order and CSV row order do not decide where a vehicle is now. +- The previous site is the latest earlier **different** site. Repeated records at the current site do not replace that previous distinct site. No previous evidence means an unknown previous site, not a guessed move. +- Repeated or overlapping monthly downloads are expected. Existing source observations are deduplicated rather than added again; importing older evidence may enrich history without moving a vehicle backwards. +- Conflicting latest observations that cannot be ordered safely hold that vehicle for review. Other vehicles with clear evidence can still be updated. A held vehicle must remain visible in the result; do not silently select one of its sites. +- Manual site changes append dated manual observations. Older fuel evidence does not undo a later manual move. A manual selection takes precedence over an untimed fuel observation on the same day. +- Confirmed site-name aliases live in `lib/plant-site-policy.json`. An identity conflict needs a human decision; do not alter a QR or add a speculative alias to make an import pass. +- A real site name can be recorded without coordinates. It remains awaiting a map pin until a person supplies one in the map sidebar. Never substitute headquarters or invent latitude/longitude. + +Source files and upstream records remain unchanged. History records are location evidence, not new fuel transactions. The latest recorded site is not a live GPS location. Refresh the Plant screen after importing to see the updated site and history. + +## Review and apply one file + +Use fresh result paths and an absolute CSV path. Preview reads the proposed work without applying it: + +```sh +cd /Volumes/EXT/DEVELOPMENT/PLANT_TEST +node --import tsx scripts/import-plant-sites.ts \ + --file /absolute/snapshot/fuel-logs-all-sites.csv \ + --preview --result /absolute/new-run/preview.json +``` + +Read `changes`, `issues`, `unknownQrs`, and `newSites`. The preview reports each matched vehicle's current and previous sites, observation dates, status, and whether it needs coordinates. `canApply: true` means there is safe work to apply; it does not mean every vehicle is unambiguous. + +Apply the unchanged source using the exact `fileHash` from that preview: + +```sh +node --import tsx scripts/import-plant-sites.ts \ + --file /absolute/snapshot/fuel-logs-all-sites.csv \ + --apply --expected-hash PREVIEW_FILE_HASH \ + --result /absolute/new-run/apply.json +``` + +Keep the apply result, including `appliedVehicles`, `insertedObservations`, `unchangedVehicles` and `heldVehicles`. Preview the same file again into a new `verify.json`. Safe work must be exhausted (`canApply: false`); held issues may remain and must be reported separately. If verification fails, preserve all evidence and investigate before reporting completion. A failed command may have made partial progress; a retry uses the same preserved source and a new evidence directory. + +## Scheduled download bridge + +The Buildsite checkout contains `scripts/sync-plant-sites-from-buildsite.mjs`. After the scheduled downloader has validated and preserved the complete previous-month and current-month snapshots, run: + +```sh +cd /Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE +node scripts/sync-plant-sites-from-buildsite.mjs \ + --file /absolute/previous-month/fuel-logs-all-sites.csv \ + --file /absolute/current-month/fuel-logs-all-sites.csv \ + --out /absolute/new-scheduled-run/plant-sites +``` + +Use oldest first. `--file` is repeatable; `--out` must be a new directory. The default Plant checkout is sibling `PLANT_TEST`; `--plant-root /absolute/plant-checkout` overrides it. The bridge is a consumer of already downloaded files and does not log into Buildsite or install a schedule. + +For each source, the bridge runs Plant's CLI in the Plant checkout, previews, applies with the preview hash only if safe work exists, then previews again. Each child has a 120-second timeout. Numbered evidence folders contain `preview.json`, optional `apply.json`, `verify.json`, and child stdout/stderr logs. `summary.json` records sources, hashes, outcomes and review issues. No previous evidence directory is overwritten. + +Exit codes: + +| Code | Meaning | Scheduled report | +| --- | --- | --- | +| `0` | All safe work verified | Report updated or unchanged counts and ignored upstream QRs. | +| `1` | Importer, connectivity, schema, source or verification failure | Report failure and evidence path; do not claim completion. Later sources are not applied. | +| `2` | Safe work verified, vehicles held for review | Report the safe updates and list each held QR/reason separately. | + +If the Plant checkout or dependencies are missing, fix that deployment before retrying. If the database is unreachable or the schema is absent, report the failure instead of skipping Plant synchronization. Preserve the original download and use a new output directory on every retry. + +Offline bridge tests (no database reads or writes): + +```sh +node --test /Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/scripts/sync-plant-sites-from-buildsite.test.mjs +``` diff --git a/docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md b/docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md new file mode 100644 index 0000000..e39716e --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md @@ -0,0 +1,25 @@ +# Fuel-log vehicle site history + +User request: compare the supplied CSV against the app, use downloaded logs to place existing plant by its last recorded site, and display current and previous site in vehicle details. + +Implementation decisions: + +- QR is the exact vehicle join. Do not match a vehicle by name or reinterpret a meter reading as a QR. TLB06 QR correction awaits the owner. +- Parse Date Logged from the file, not its misleading filename. Extended scheduled exports can also supply verified Logged At timestamps. +- Current means latest recorded site, not GPS. Previous means the most recent different site. Same-site fills are observations, not moves. +- Preserve dated observations independently of replaceable asset IDs, then materialize current/previous labels, dates and site relations on assets. +- Repeated snapshots are idempotent, older files can enrich history but cannot reverse a newer move, ambiguous untimed same-day moves are held. +- Manual site changes enter the same dated history and take precedence over untimed same-day observations. +- Resolve sites by exact normalized names or explicitly confirmed aliases only. Preserve source labels. Create a real missing site only for a matched vehicle; do not invent coordinates. Unknown pins remain unlocated. +- CSV preview shows changes and warnings before apply. Import updates sites/location metadata only; no income, costs or fuel measurements change. +- Reuse the existing Buildsite schedule through a small CLI bridge after its complete downloads. Keep the source downloads and return import evidence. + +Tasks: + +- [x] Test date parsing, QR precision, previous-distinct-site, overlapping files and ambiguity before implementation. +- [x] Add shared import/history service, schema setup and authenticated preview/apply endpoint and CLI. +- [x] Preserve fields through seed and manual changes; update map location resolution. +- [x] Show current/previous/source dates in register and map details and add CSV preview/apply controls. +- [x] Connect the existing recurring download through the CLI bridge. +- [x] Preview the provided 615-row file, back up affected records, apply only clean matched entries and verify repeat no-op. +- [x] Verify automated tests, build/typecheck and affected pages at 375×812 and 768×1024, preserving unrelated edits. diff --git a/lib/data.ts b/lib/data.ts index d25d090..9c5a6d7 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -26,6 +26,13 @@ export type Asset = { // /plant → Edit → Site. cascadeDelete is OFF, so removing a site never // removes the machines that referenced it. site: string; + /** Last recorded location and the last different site, derived from dated observations. */ + site_label?: string; + previous_site?: string; + previous_site_label?: string; + site_observed_on?: string; + previous_site_observed_on?: string; + site_source?: string; // Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as // "not located" — see `hasCoords` in components/PlantMap. latitude: number | null; diff --git a/lib/plant-map.ts b/lib/plant-map.ts index 173f7e7..4c85be3 100644 --- a/lib/plant-map.ts +++ b/lib/plant-map.ts @@ -3,7 +3,7 @@ import headOffice from "./plant-location-policy.json"; export const HEAD_OFFICE = headOffice; -export type MapAsset = Pick; +export type MapAsset = Pick; export type MapSite = Pick; type Coordinates = { latitude: number | null; longitude: number | null }; export type MapLocation = Coordinates & { @@ -20,13 +20,15 @@ export function hasCoords(point: Coordinates): boolean { // Current assignment controls placement, independently of historic invoice hires. // Resolve at read time so existing assignments and site-coordinate edits work // immediately, without overwriting a machine's separately saved manual pin. -export function resolveAssetLocation(asset: Coordinates & { site?: string }, sites: MapSite[]): MapLocation { +export function resolveAssetLocation(asset: Coordinates & { site?: string; site_label?: string }, sites: MapSite[]): MapLocation { if (asset.site) { const site = sites.find(s => s.id === asset.site); return site && hasCoords(site) ? { latitude: site.latitude, longitude: site.longitude, source: "site", siteName: site.name } : { latitude: null, longitude: null, source: "missing-site", siteName: site?.name ?? "Unknown site" }; } + // A recorded site without a resolved/pinned yard must not leave a phantom HQ pin. + if (asset.site_label) return { latitude: null, longitude: null, source: "missing-site", siteName: asset.site_label }; if (!hasCoords(asset)) return { latitude: null, longitude: null, source: "unlocated", siteName: null }; const atOffice = Math.abs(asset.latitude! - HEAD_OFFICE.latitude) < 0.000001 && Math.abs(asset.longitude! - HEAD_OFFICE.longitude) < 0.000001; diff --git a/lib/plant-site-history.test.ts b/lib/plant-site-history.test.ts new file mode 100644 index 0000000..3e6a49c --- /dev/null +++ b/lib/plant-site-history.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseFuelSiteCsv, deriveSiteHistory, canonicalSiteName, siteObservationKey } from './plant-site-history'; + +const header = "Date Logged,Vehicle QR,Vehicle,Km's/ Hours,Latest Consumption,Litres Pumped,Current Site\r\n"; +test('native CSV reads trimmed dates and QR, quoted names and trailing site whitespace', () => { + const p = parseFuelSiteCsv('\uFEFF' + header + '" 03/11/2026",01089,"Case, TLB",4767,4,36,"The Hills "\r\n'); + assert.equal(p.totalRows, 1); assert.deepEqual(p.errors, []); + assert.deepEqual(p.observations[0], { qr: '01089', name: 'Case, TLB', siteName: 'The Hills', observedOn: '2026-11-03', row: 2 }); +}); +test('the native date column controls the period, never the filename', () => { + const p = parseFuelSiteCsv(header + '01/10/2026,1123,Grader,1,1,1,Castle Gate\n03/11/2026,1123,Grader,2,1,1,West Park\n'); + assert.deepEqual(p.observations.map(o => o.observedOn), ['2026-10-01', '2026-11-03']); +}); +test('bad dates, incomplete rows and missing required headers are rejected', () => { + assert.ok(parseFuelSiteCsv(header + '31/02/2026,1089,TLB,1,1,1,The Hills\n').errors.length); + assert.ok(parseFuelSiteCsv(header + '03/11/2026,,TLB,1,1,1,The Hills\n').errors.length); + assert.ok(parseFuelSiteCsv('Vehicle QR,Current Site\n1089,Hills\n').errors.length); +}); +test('current is latest dated site and previous is the last different site, not the previous fill', () => { + const state = deriveSiteHistory([ + {siteName:'Castle Gate',observedOn:'2026-10-13'}, + {siteName:'West Park',observedOn:'2026-11-02'}, + {siteName:'West Park',observedOn:'2026-10-30'}, + ]); + assert.equal(state.current?.siteName, 'West Park'); + assert.equal(state.current?.observedOn, '2026-11-02'); + assert.equal(state.previous?.siteName, 'Castle Gate'); +}); +test('a return to an earlier site retains the intervening site as previous', () => { + const state = deriveSiteHistory([{siteName:'A',observedOn:'2026-10-01'},{siteName:'B',observedOn:'2026-10-05'},{siteName:'A',observedOn:'2026-10-08'}]); + assert.equal(state.current?.siteName,'A');assert.equal(state.previous?.siteName,'B'); +}); +test('repeated and older files cannot move a machine back or invent previous history', () => { + const obs = [{siteName:'West Park',observedOn:'2026-11-03'},{siteName:'Castle Gate',observedOn:'2026-10-01'}]; + assert.deepEqual(deriveSiteHistory([...obs,...obs].reverse()),deriveSiteHistory(obs)); + assert.equal(deriveSiteHistory([obs[0],obs[0]]).previous,null); +}); +test('two different sites on the latest day with no times are held, not ordered by CSV row', () => { + const state=deriveSiteHistory([{siteName:'A',observedOn:'2026-11-03'},{siteName:'B',observedOn:'2026-11-03'}]); + assert.equal(state.current,null);assert.match(state.issue||'',/same day/i); +}); +test('verified source timestamps can order same-day movement, coarse duplicates cannot override them', () => { + const state=deriveSiteHistory([{siteName:'A',observedOn:'2026-11-03T06:00:00.000Z'},{siteName:'B',observedOn:'2026-11-03T10:00:00.000Z'},{siteName:'A',observedOn:'2026-11-03'}]); + assert.equal(state.current?.siteName,'B');assert.equal(state.previous?.siteName,'A'); +}); +test('manual assignment wins over an untimed fuel entry on the same date', () => { + const state=deriveSiteHistory([{siteName:'A',observedOn:'2026-11-03'},{siteName:'B',observedOn:'2026-11-03T10:00:00.000Z',source:'manual'}]); + assert.equal(state.current?.siteName,'B');assert.equal(state.previous?.siteName,'A'); +}); +test('equal precise timestamps at different sites are held rather than using input order',()=>{ + const s=deriveSiteHistory([{siteName:'A',observedOn:'2026-11-03T10:00:00Z'},{siteName:'B',observedOn:'2026-11-03T10:00:00Z'}]); + assert.equal(s.current,null);assert.match(s.issue||'',/same timestamp/); +}); +test('site names only use explicit aliases and ignore case/extra whitespace', () => { + assert.equal(canonicalSiteName(' West Park ',{}),'West Park'); + assert.equal(canonicalSiteName('Equestria Bastion Job',{}),'Equestria Bastion Job'); + assert.equal(canonicalSiteName('equEstria bastion job',{'Equestria Bastion Job':'Equestria 215'}),'Equestria 215'); +}); +test('event identity is stable across repeated CSV snapshots and same-day same-site fills', () => { + assert.equal(siteObservationKey('01089','2026-11-03','The Hills'),siteObservationKey('01089','2026-11-03T09:00:00Z',' the hills ')); + assert.notEqual(siteObservationKey('01089','2026-11-03','The Hills'),siteObservationKey('1089','2026-11-03','The Hills')); +}); diff --git a/lib/plant-site-history.ts b/lib/plant-site-history.ts new file mode 100644 index 0000000..9f20910 --- /dev/null +++ b/lib/plant-site-history.ts @@ -0,0 +1,88 @@ +/** A site observation, not GPS and not a financial posting. Date-only logs never imply a time. */ +export type SiteObservation = { siteName: string; observedOn: string; source?: string }; +export type FuelSiteObservation = SiteObservation & { qr: string; name: string; row: number }; +export const siteNameKey = (name: string) => name.trim().replace(/\s+/g, ' ').toLowerCase(); +export function canonicalSiteName(name: string, aliases: Record): string { + const found = Object.entries(aliases).find(([from]) => siteNameKey(from) === siteNameKey(name)); + return (found?.[1] ?? name).trim().replace(/\s+/g, ' '); +} +export function observedDay(value: string): string { + return value.length === 10 ? value : new Date(Date.parse(value) + 7_200_000).toISOString().slice(0, 10); +} +export const siteObservationKey = (qr: string, date: string, site: string) => + [qr.trim(), observedDay(date), siteNameKey(site)].join('\u001f'); + +function csvRows(text: string): string[][] { + const rows: string[][] = []; let row: string[] = [], cell = '', quoted = false; + text = text.replace(/^\uFEFF/, ''); + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"') { + if (quoted && text[i + 1] === '"') { cell += '"'; i++; } + else if (quoted || cell.trim() === '') quoted = !quoted; + else cell += c; + } else if (!quoted && (c === ',' || c === '\n' || c === '\r')) { + row.push(cell); cell = ''; + if (c !== ',') { if (row.some(v => v.trim())) rows.push(row); row = []; if (c === '\r' && text[i + 1] === '\n') i++; } + } else cell += c; + } + if (quoted) throw new Error('The CSV contains an unclosed quoted field.'); + row.push(cell); if (row.some(v => v.trim())) rows.push(row); + return rows; +} +function parseDay(raw: string): string | null { + const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw.trim()); + if (!match) return null; + const [, d, m, y] = match, value = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`; + const ms = Date.parse(value + 'T00:00:00Z'); + return Number(y) >= 2000 && Number(y) <= 2100 && Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === value ? value : null; +} +export function parseFuelSiteCsv(text: string): { totalRows: number; observations: FuelSiteObservation[]; errors: string[] } { + let rows: string[][]; + try { rows = csvRows(text); } catch (error) { return { totalRows: 0, observations: [], errors: [(error as Error).message] }; } + const headers = rows.shift()?.map(h => h.trim()) ?? []; + const required = ['Date Logged', 'Vehicle QR', 'Vehicle', 'Current Site']; + const missing = required.filter(h => !headers.includes(h)); + if (missing.length || new Set(headers).size !== headers.length) return { totalRows: rows.length, observations: [], errors: [missing.length ? `Missing columns: ${missing.join(', ')}` : 'Repeated CSV column names.'] }; + const get = (r: string[], h: string) => (r[headers.indexOf(h)] ?? '').trim(); + const observations: FuelSiteObservation[] = [], errors: string[] = []; + rows.forEach((r, i) => { + const qr = get(r, 'Vehicle QR'), name = get(r, 'Vehicle'), siteName = get(r, 'Current Site').replace(/\s+/g, ' '), day = parseDay(get(r, 'Date Logged')); + if (!day || !qr || !siteName || r.length !== headers.length) { errors.push(`Row ${i + 2}: ${!day ? 'invalid Date Logged' : !qr ? 'missing vehicle QR' : !siteName ? 'missing site' : 'column count does not match the header'}.`); return; } + const at = get(r, 'Logged At'); + if (at && (!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(at) || !Number.isFinite(Date.parse(at)) || observedDay(at) !== day)) { errors.push(`Row ${i + 2}: Logged At does not match Date Logged in South Africa.`); return; } + observations.push({ qr, name, siteName, observedOn: at || day, row: i + 2 }); + }); + return { totalRows: rows.length, observations, errors }; +} + +/** Pick observations by evidence time. Never use CSV row order to infer same-day moves. */ +export function deriveSiteHistory(input: SiteObservation[]): { current: SiteObservation | null; previous: SiteObservation | null; issue: string | null } { + const byDay = new Map(); + for (const o of input) { const day = observedDay(o.observedOn); byDay.set(day, [...(byDay.get(day) ?? []), o]); } + const ordered: SiteObservation[] = []; + for (const day of [...byDay.keys()].sort().reverse()) { + const group = byDay.get(day)!; + const exact = group.filter(o => o.observedOn.length > 10); + const manual = exact.filter(o => o.source === 'manual').sort((a, b) => Date.parse(b.observedOn) - Date.parse(a.observedOn)); + const names = new Set(group.map(o => siteNameKey(o.siteName))); + const preciseNames = new Set(exact.map(o => siteNameKey(o.siteName))); + const times = new Map>(); + for (const o of exact) { const t = new Date(o.observedOn).toISOString(); const namesAt = times.get(t) ?? new Set(); namesAt.add(siteNameKey(o.siteName)); times.set(t,namesAt); } + if ([...times.values()].some(n => n.size > 1)) return { current: ordered[0] ?? null, previous: null, issue: `Different sites have the same timestamp on ${day}.` }; + if (names.size > 1 && !manual.length && [...names].some(n => !preciseNames.has(n))) { + const issue = `Different sites on the same day (${day}) have no reliable order.`; + return { current: ordered[0] ?? null, previous: ordered.find(o => siteNameKey(o.siteName) !== siteNameKey(ordered[0].siteName)) ?? null, issue }; + } + const sorted = group.filter(o => o.observedOn.length > 10 || !preciseNames.has(siteNameKey(o.siteName))) + .sort((a, b) => Date.parse(b.observedOn) - Date.parse(a.observedOn)); + if (manual.length) { + // A deliberate manual choice takes precedence over date-only source entries that day. + ordered.push(manual[0], ...sorted.filter(o => o !== manual[0])); + } else ordered.push(...sorted); + const current = ordered[0]; + const previous = ordered.find(o => siteNameKey(o.siteName) !== siteNameKey(current.siteName)); + if (previous) return { current, previous, issue: null }; + } + return { current: ordered[0] ?? null, previous: null, issue: null }; +} diff --git a/lib/plant-site-import.test.ts b/lib/plant-site-import.test.ts new file mode 100644 index 0000000..30a44f6 --- /dev/null +++ b/lib/plant-site-import.test.ts @@ -0,0 +1,181 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type PocketBase from 'pocketbase'; +import { applyPlantSites, previewPlantSites, SITE_EVENTS, SITE_LOCKS, updateAssetWithSiteHistory, withPlantSiteLock } from './plant-site-import'; + +type Row = { id: string; [key: string]: unknown }; +class FakePb { + data: Record = { + assets: [ + { id: 'asset1', code: 'PLANT01', name: 'Exact one', qr_code: '1001', site: 'siteA' }, + { id: 'asset2', code: 'PLANT02', name: 'Exact two', qr_code: '1002', site: '' }, + ], + sites: [{ id: 'siteA', name: 'Yard A', latitude: -25, longitude: 28 }, { id: 'siteB', name: 'Yard B' }], + [SITE_EVENTS]: [], [SITE_LOCKS]: [], + }; + writes: { collection: string; operation: string; id: string }[] = []; + private serial = 0; + filter(_expression: string, values: Record) { return JSON.stringify(values); } + collection(name: string) { + const rows = () => { + if (!this.data[name]) throw Object.assign(new Error(`Missing collection ${name}`), { status: 404 }); + return this.data[name]; + }; + const find = (id: string) => { + const row = rows().find(r => r.id === id); + if (!row) throw Object.assign(new Error('Not found'), { status: 404 }); + return row; + }; + return { + getFullList: async (options?: { filter?: string }) => { + const all = rows(); + const qr = options?.filter ? JSON.parse(options.filter).qr : undefined; + return structuredClone(qr === undefined ? all : all.filter(r => r.vehicle_qr === qr)); + }, + getOne: async (id: string) => structuredClone(find(id)), + create: async (input: Record) => { + const row = structuredClone({ ...input, id: String(input.id || `new${++this.serial}`) }); + if (rows().some(r => r.id === row.id)) throw Object.assign(new Error('Duplicate id'), { status: 400 }); + rows().push(row); + this.writes.push({ collection: name, operation: 'create', id: row.id }); + return structuredClone(row); + }, + update: async (id: string, patch: Record) => { + Object.assign(find(id), structuredClone(patch)); + this.writes.push({ collection: name, operation: 'update', id }); + return structuredClone(find(id)); + }, + delete: async (id: string) => { + find(id); + this.data[name] = rows().filter(r => r.id !== id); + this.writes.push({ collection: name, operation: 'delete', id }); + }, + }; + } + asPb() { return this as unknown as PocketBase; } +} +const csv = (...rows: string[]) => ['Date Logged,Vehicle QR,Vehicle,Current Site', ...rows].join('\n'); +const historyCsv = csv( + '01/09/2026,1001,Exact one,Yard A', + '03/09/2026,1001,Exact one,Yard B', + '05/09/2026,1001,Exact one,Yard B', +); +async function apply(fake: FakePb, text: string) { + const preview = await previewPlantSites(fake.asPb(), text, 'source.csv'); + return applyPlantSites(fake.asPb(), text, 'source.csv', preview.fileHash); +} + +test('preview uses exact QR, reports unknown vehicles, and never writes', async () => { + const fake = new FakePb(); + const source = csv('01/09/2026,1001,Renamed upstream,Yard B', '01/09/2026,01001,Exact one,Yard A'); + const preview = await previewPlantSites(fake.asPb(), source, 'source.csv'); + assert.equal(preview.matchedVehicles, 1); + assert.equal(preview.canApply, true); + assert.equal(preview.ignoredRows, 1); + assert.deepEqual(preview.unknownQrs, [{ qr: '01001', name: 'Exact one', rows: 1 }]); + assert.equal(preview.changes[0].currentSite, 'Yard B'); + assert.equal(preview.changes[0].needsCoordinates, true); + assert.equal(fake.writes.length, 0); +}); + +test('apply records repeated-site observations, keeps previous distinct site, and is idempotent', async () => { + const fake = new FakePb(); + const first = await apply(fake, historyCsv); + assert.equal(first.appliedVehicles, 1); + assert.equal(first.insertedObservations, 3); + const asset = fake.data.assets[0]; + assert.equal(asset.site, 'siteB'); + assert.equal(asset.site_label, 'Yard B'); + assert.equal(asset.previous_site, 'siteA'); + assert.equal(asset.previous_site_label, 'Yard A'); + assert.equal(asset.site_observed_on, '2026-09-05'); + assert.equal(asset.previous_site_observed_on, '2026-09-01'); + const again = await apply(fake, historyCsv); + assert.equal(again.canApply, false); + assert.equal(again.appliedVehicles, 0); + assert.equal(again.insertedObservations, 0); + assert.equal(fake.data[SITE_EVENTS].length, 3); + assert.equal(fake.data[SITE_LOCKS].length, 0); +}); + +test('older imports enrich history without moving a vehicle backwards', async () => { + const fake = new FakePb(); + await apply(fake, csv('05/09/2026,1001,Exact one,Yard B')); + const result = await apply(fake, csv('01/09/2026,1001,Exact one,Yard A')); + assert.equal(result.insertedObservations, 1); + assert.equal(fake.data.assets[0].site, 'siteB'); + assert.equal(fake.data.assets[0].site_observed_on, '2026-09-05'); + assert.equal(fake.data.assets[0].previous_site_label, 'Yard A'); +}); + +test('new named sites have no fabricated coordinates and upstream-only vehicles are never created', async () => { + const fake = new FakePb(); + const result = await apply(fake, csv('05/09/2026,1001,Exact one,New real yard', '05/09/2026,9999,Upstream truck,Other site')); + assert.deepEqual(result.newSites, ['New real yard']); + const site = fake.data.sites.find(s => s.name === 'New real yard')!; + assert.ok(site); + assert.equal(site.latitude, undefined); + assert.equal(site.longitude, undefined); + assert.equal(fake.data.assets.length, 2); + assert.equal(fake.data[SITE_EVENTS].length, 1); + assert.equal(fake.data.assets[0].site, site.id); +}); + +test('ambiguous latest dates hold one vehicle while clean vehicles apply', async () => { + const fake = new FakePb(); + const source = csv('05/09/2026,1001,Exact one,Yard A', '05/09/2026,1001,Exact one,Yard B', '05/09/2026,1002,Exact two,Yard B'); + const preview = await previewPlantSites(fake.asPb(), source, 'source.csv'); + assert.equal(preview.canApply, true); + assert.deepEqual(preview.issues.map(i => i.qr), ['1001']); + assert.equal(preview.pendingObservations, 1); + const result = await applyPlantSites(fake.asPb(), source, 'source.csv', preview.fileHash); + assert.equal(result.heldVehicles, 1); + assert.equal(fake.data.assets[0].site, 'siteA'); + assert.equal(fake.data.assets[1].site, 'siteB'); + assert.deepEqual(fake.data[SITE_EVENTS].map(e => e.vehicle_qr), ['1002']); + const verify = await previewPlantSites(fake.asPb(), source, 'source.csv'); + assert.equal(verify.canApply, false); + assert.equal(verify.pendingObservations, 0); + assert.equal(verify.issues.length, 1); +}); + +test('manual moves and explicit clearing survive later imports of older evidence', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: new Date('2026-10-02T10:00:00Z') }); + const fake = new FakePb(); + await apply(fake, historyCsv); + await updateAssetWithSiteHistory(fake.asPb(), 'asset1', { site: 'siteA' }); + assert.equal(fake.data.assets[0].site_source, 'manual'); + assert.equal(fake.data.assets[0].site, 'siteA'); + assert.equal(fake.data.assets[0].previous_site_label, 'Yard B'); + await apply(fake, historyCsv); + assert.equal(fake.data.assets[0].site, 'siteA'); + t.mock.timers.tick(1000); + await updateAssetWithSiteHistory(fake.asPb(), 'asset1', { site: '' }); + assert.equal(fake.data.assets[0].site, ''); + assert.equal(fake.data.assets[0].site_label, ''); + await apply(fake, historyCsv); + assert.equal(fake.data.assets[0].site, ''); + assert.equal(fake.data.assets[0].site_source, 'manual'); +}); + +test('file hash mismatches reject before acquiring a lock or writing', async () => { + const fake = new FakePb(); + await assert.rejects(applyPlantSites(fake.asPb(), historyCsv, 'source.csv', 'bad-hash'), /file changed/i); + assert.equal(fake.writes.length, 0); +}); + +test('concurrent lock holders are refused and failures release the owned lock', async () => { + const fake = new FakePb(); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let acquired!: () => void; + const started = new Promise(resolve => { acquired = resolve; }); + const first = withPlantSiteLock(fake.asPb(), async () => { acquired(); await gate; }); + await started; + await assert.rejects(withPlantSiteLock(fake.asPb(), async () => assert.fail('must not enter')), /Another site update/); + assert.equal(fake.data[SITE_LOCKS].length, 1); + release(); await first; + assert.equal(fake.data[SITE_LOCKS].length, 0); + await assert.rejects(withPlantSiteLock(fake.asPb(), async () => { throw new Error('planned failure'); }), /planned failure/); + assert.equal(fake.data[SITE_LOCKS].length, 0); +}); diff --git a/lib/plant-site-import.ts b/lib/plant-site-import.ts new file mode 100644 index 0000000..71c41ad --- /dev/null +++ b/lib/plant-site-import.ts @@ -0,0 +1,150 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type PocketBase from 'pocketbase'; +import type { RecordModel } from 'pocketbase'; +import policy from './plant-site-policy.json'; +import headOffice from './plant-location-policy.json'; +import { canonicalSiteName, deriveSiteHistory, observedDay, parseFuelSiteCsv, siteNameKey, siteObservationKey, type SiteObservation } from './plant-site-history'; + +export const SITE_EVENTS = 'plant_site_observations'; +export const SITE_LOCKS = 'plant_site_sync_locks'; +const LOCK_ID = 'plntsite0000001'; +const digest = (s: string) => createHash('sha256').update(s).digest('hex'); +type Event = { id: string; vehicle_qr: string; asset_code: string; site_name: string; observed_on: string; source: string; source_file: string; source_hash: string }; +type Change = { assetId: string; code: string; qr: string; currentSite: string; previousSite: string; lastSeen: string; previousSeen: string; status: string; needsCoordinates: boolean }; +export type SiteImportPreview = { + ok: true; fileHash: string; totalRows: number; matchedVehicles: number; observationCount: number; pendingObservations: number; ignoredRows: number; + unknownQrs: {qr: string; name: string; rows: number}[]; issues: {qr: string; reason: string}[]; changes: Change[]; newSites: string[]; canApply: boolean; +}; +type Item = { asset: RecordModel; events: Event[]; current: SiteObservation; previous: SiteObservation | null }; +const qrOf = (a: RecordModel) => String(a.qr_code || a.code || '').trim(); +const configuredName = (s: string) => canonicalSiteName(s, policy.aliases); +const isHeld = (s: string) => policy.heldSiteNames.some(n => siteNameKey(n) === siteNameKey(s)); +const eventId = (qr: string, at: string, site: string, source: string) => digest(siteObservationKey(qr, at, site) + '\u001f' + source).slice(0, 15); +const hasPin = (s?: RecordModel) => !!s && typeof s.latitude === 'number' && typeof s.longitude === 'number' && (s.latitude !== 0 || s.longitude !== 0); +const same = (a: unknown, b: unknown) => String(a ?? '') === String(b ?? ''); +function preferred(a: Event, b: Event): Event { + if (a.observed_on.length > 10 && b.observed_on.length === 10) return a; + if (b.observed_on.length > 10 && a.observed_on.length === 10) return b; + return b.observed_on > a.observed_on ? b : a; +} +function findSite(sites: RecordModel[], name: string): RecordModel | undefined { + const found = sites.filter(s => siteNameKey(s.name) === siteNameKey(configuredName(name))); + if (found.length > 1) throw new Error(`More than one site is named ${name}; resolve the duplicate before importing.`); + return found[0]; +} +function metadata(current: SiteObservation, previous: SiteObservation | null, sites: RecordModel[]) { + const currentSite = current.siteName ? findSite(sites, current.siteName) : undefined; + const previousSite = previous?.siteName ? findSite(sites, previous.siteName) : undefined; + return { site: currentSite?.id || '', site_label: current.siteName ? (currentSite?.name || configuredName(current.siteName)) : '', + previous_site: previousSite?.id || '', previous_site_label: previous?.siteName ? (previousSite?.name || configuredName(previous.siteName)) : '', + site_observed_on: current.observedOn, previous_site_observed_on: previous?.observedOn || '', site_source: current.source || 'fuel-log' }; +} +async function prepare(pb: PocketBase, text: string, filename: string) { + const parsed = parseFuelSiteCsv(text); + if (parsed.errors.length) throw new Error(parsed.errors.slice(0, 8).join('\n')); + if (!parsed.totalRows) throw new Error('This file has no fuel entries.'); + const today = new Date(Date.now() + 7_200_000).toISOString().slice(0, 10); + if (parsed.observations.some(o => observedDay(o.observedOn) > today)) throw new Error('A fuel entry is dated in the future; no locations were imported.'); + const [assets, sites, stored] = await Promise.all([pb.collection('assets').getFullList(), pb.collection('sites').getFullList(), pb.collection(SITE_EVENTS).getFullList()]); + const byQr = new Map(); + for (const a of assets) { const q = qrOf(a); if (q) byQr.set(q, [...(byQr.get(q) ?? []), a]); } + const incoming = new Map(), unknown = new Map(); + const issues: SiteImportPreview['issues'] = []; let ignoredRows = 0; + const fileHash = digest(text); + for (const row of parsed.observations) { + const found = byQr.get(row.qr) ?? []; + if (!found.length) { ignoredRows++; const u = unknown.get(row.qr) ?? {qr:row.qr,name:row.name,rows:0}; u.rows++; unknown.set(row.qr,u); continue; } + if (found.length !== 1) { if (!issues.some(i => i.qr === row.qr)) issues.push({qr:row.qr,reason:'QR matches more than one Plant asset.'}); continue; } + const a = found[0]; + const e: Event = { id:eventId(row.qr,row.observedOn,row.siteName,'fuel-log'),vehicle_qr:row.qr,asset_code:String(a.code||''),site_name:row.siteName,observed_on:row.observedOn,source:'fuel-log',source_file:filename.slice(0,500),source_hash:fileHash }; + incoming.set(row.qr,[...(incoming.get(row.qr) ?? []),e]); + } + const existing = new Map(stored.map(e => [e.id,e])); + const items: Item[] = [], changes: Change[] = [], newSites = new Set(); + let pendingObservations = 0, observationCount = 0; + for (const [qr, rows] of incoming) { + const asset = byQr.get(qr)![0], merged = new Map(); + for (const e of [...stored.filter(e => e.vehicle_qr === qr),...rows]) merged.set(e.id, merged.has(e.id) ? preferred(merged.get(e.id)!,e) : e); + const events = [...merged.values()]; + const history = deriveSiteHistory(events.map(e => ({siteName:configuredName(e.site_name),observedOn:e.observed_on,source:e.source}))); + if (!history.current) { issues.push({qr,reason:history.issue || 'No dated site available.'}); continue; } + if (isHeld(history.current.siteName)) { issues.push({qr,reason:`Site identity awaiting confirmation: ${history.current.siteName}.`}); continue; } + let currentSite: RecordModel | undefined; + try { + currentSite = findSite(sites,history.current.siteName); + if (history.previous && !isHeld(history.previous.siteName)) findSite(sites,history.previous.siteName); + } catch (error) { issues.push({qr,reason:(error as Error).message}); continue; } + if (history.issue) issues.push({qr,reason:`Current site is known; earlier history needs review: ${history.issue}`}); + if (history.previous && isHeld(history.previous.siteName)) issues.push({qr,reason:`Previous site label retained; identity awaiting confirmation: ${history.previous.siteName}.`}); + const needed = events.filter(e => !existing.has(e.id) || preferred(existing.get(e.id)!,e).observed_on !== existing.get(e.id)!.observed_on); + const fields = metadata(history.current,history.previous,sites); + const changed = Object.entries(fields).some(([k,v]) => !same(asset[k],v)); + const moved = !same(asset.site_label || sites.find(s=>s.id===asset.site)?.name,fields.site_label); + changes.push({assetId:asset.id,code:String(asset.code||asset.name),qr,currentSite:fields.site_label || 'Unassigned',previousSite:fields.previous_site_label || '',lastSeen:fields.site_observed_on,previousSeen:fields.previous_site_observed_on,status:!changed?'unchanged':moved?'move':'history',needsCoordinates:!!fields.site_label&&!hasPin(currentSite)&&siteNameKey(fields.site_label)!=='mega head office'}); + if (changed || needed.length) { + items.push({asset,events:needed,current:history.current,previous:history.previous}); pendingObservations += needed.length; + for (const o of [history.current,history.previous]) if (o?.siteName && !isHeld(o.siteName) && !findSite(sites,o.siteName)) newSites.add(configuredName(o.siteName)); + } + observationCount += new Set(rows.map(e=>e.id)).size; + } + const preview: SiteImportPreview = {ok:true,fileHash,totalRows:parsed.totalRows,matchedVehicles:incoming.size,observationCount,pendingObservations,ignoredRows,unknownQrs:[...unknown.values()].sort((a,b)=>a.qr.localeCompare(b.qr)),issues,changes,newSites:[...newSites].sort(),canApply:items.length>0}; + return {preview,items,sites}; +} +export async function previewPlantSites(pb: PocketBase, text: string, filename: string) { return (await prepare(pb,text,filename)).preview; } + +/** Shared database lease; neither web uploads nor scheduled CLI runs can race a site update. */ +export async function withPlantSiteLock(pb: PocketBase, work: () => Promise): Promise { + const owner = randomUUID(); + try { await pb.collection(SITE_LOCKS).create({id:LOCK_ID,owner,started_at:new Date().toISOString()}); } + catch { throw new Error('Another site update is running, or the site import schema is missing. Do not steal an existing lock; check its owner first.'); } + try { return await work(); } + finally { const lock = await pb.collection(SITE_LOCKS).getOne(LOCK_ID); if (lock.owner !== owner) throw new Error('Site import lock owner changed unexpectedly.'); await pb.collection(SITE_LOCKS).delete(LOCK_ID); } +} +export async function applyPlantSites(pb: PocketBase, text: string, filename: string, expectedHash: string) { + if (!expectedHash || digest(text) !== expectedHash) throw new Error('The file changed after preview. Preview it again before applying.'); + return withPlantSiteLock(pb,async()=>{ + // Recompute under the lock. A preview cannot overwrite a newer import or manual move. + const {preview,items,sites} = await prepare(pb,text,filename); + let insertedObservations = 0, appliedVehicles = 0; + for (const name of preview.newSites) { + const geo = siteNameKey(name)==='mega head office' ? {latitude:headOffice.latitude,longitude:headOffice.longitude} : {}; + sites.push(await pb.collection('sites').create({name,is_active:true,...geo})); + } + for (const item of items) { + for (const e of item.events) { + const old = await pb.collection(SITE_EVENTS).getOne(e.id).catch(error => { if (error.status===404) return null; throw error; }); + if (old) { if (preferred(old as unknown as Event,e).observed_on!==old.observed_on) await pb.collection(SITE_EVENTS).update(e.id,e); } + else { await pb.collection(SITE_EVENTS).create(e); insertedObservations++; } + } + const fields = metadata(item.current,item.previous,sites); + if (Object.entries(fields).some(([k,v])=>!same(item.asset[k],v))) { await pb.collection('assets').update(item.asset.id,fields); appliedVehicles++; } + } + return {...preview,appliedVehicles,insertedObservations,unchangedVehicles:preview.changes.length-appliedVehicles,heldVehicles:preview.issues.filter(i=>!preview.changes.some(c=>c.qr===i.qr)).length}; + }); +} + +/** Manual changes share the history, so subsequent older downloads cannot undo them. */ +export async function updateAssetWithSiteHistory(pb: PocketBase, id: string, patch: Record) { + return withPlantSiteLock(pb,async()=>{ + const [asset,sites] = await Promise.all([pb.collection('assets').getOne(id),pb.collection('sites').getFullList()]); + if ('qr_code' in patch && !same(asset.qr_code,patch.qr_code)) throw new Error('Save the QR correction separately before changing this vehicle’s site.'); + if (same(asset.site,patch.site)) return pb.collection('assets').update(id,patch); + const site = patch.site ? sites.find(s=>s.id===patch.site) : undefined; + if (patch.site && !site) throw new Error('That site no longer exists.'); + const qr = qrOf(asset); if (!qr) throw new Error('Give this asset a code or QR before recording its site.'); + const at = new Date().toISOString(); const name = String(site?.name||''); + const e: Event = {id:eventId(qr,at,name,'manual'),vehicle_qr:qr,asset_code:asset.code||'',site_name:name,observed_on:at,source:'manual',source_file:'Plant register',source_hash:''}; + const old = await pb.collection(SITE_EVENTS).getOne(e.id).catch(error=>{if(error.status===404)return null;throw error;}); + if (old) await pb.collection(SITE_EVENTS).update(e.id,e); else await pb.collection(SITE_EVENTS).create(e); + const stored = await pb.collection(SITE_EVENTS).getFullList({filter:pb.filter('vehicle_qr = {:qr}',{qr})}); + const history = deriveSiteHistory(stored.map(o=>({siteName:configuredName(o.site_name),observedOn:o.observed_on,source:o.source}))); + if (!history.current) throw new Error(history.issue||'Site history cannot be ordered.'); + try { return await pb.collection('assets').update(id,{...patch,...metadata(history.current,history.previous,sites)}); } + catch (error) { + // A rejected manual save must not become a future location via an orphaned event. + if (old) await pb.collection(SITE_EVENTS).update(e.id,{...e,site_name:old.site_name,observed_on:old.observed_on,source:old.source,source_file:old.source_file,source_hash:old.source_hash}); + else await pb.collection(SITE_EVENTS).delete(e.id); + throw error; + } + }); +} diff --git a/lib/plant-site-policy.json b/lib/plant-site-policy.json new file mode 100644 index 0000000..8488bc0 --- /dev/null +++ b/lib/plant-site-policy.json @@ -0,0 +1,7 @@ +{ + "aliases": { + "Equestria Bastion Job": "Equestria 215", + "Cosmo City / Lion Park": "Cosmo City" + }, + "heldSiteNames": [] +} diff --git a/scripts/import-plant-sites.ts b/scripts/import-plant-sites.ts new file mode 100644 index 0000000..3ec6e02 --- /dev/null +++ b/scripts/import-plant-sites.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import PocketBase from 'pocketbase'; +import { previewPlantSites, applyPlantSites } from '../lib/plant-site-import'; + +async function main(){ + const args=process.argv.slice(2);if(args.includes('--help')){console.log('node --import tsx scripts/import-plant-sites.ts --file SOURCE.csv --preview|--apply [--expected-hash SHA256] --result NEW.json');return;} + const options:Record={}; + for(let i=0;i5*1024*1024)throw Error('CSV exceeds 5 MB.'); + const pb=new PocketBase(process.env.NEXT_PUBLIC_PB_URL);pb.autoCancellation(false); + if(!process.env.PB_ADMIN||!process.env.PB_PASS)throw Error('Plant PocketBase credentials are not configured.'); + await pb.collection('_superusers').authWithPassword(process.env.PB_ADMIN,process.env.PB_PASS); + const result=options['--apply']?await applyPlantSites(pb,bytes.toString('utf8'),path.basename(source),String(options['--expected-hash'])):await previewPlantSites(pb,bytes.toString('utf8'),path.basename(source)); + await handle.writeFile(JSON.stringify(result,null,2)+'\n'); + console.log(JSON.stringify({result:target,...result})); + }catch(error){await handle.writeFile(JSON.stringify({ok:false,error:(error as Error).message})+'\n');throw error;}finally{await handle.close();} +} +main().catch(error=>{console.error(JSON.stringify({ok:false,error:error.message}));process.exitCode=1;}); diff --git a/scripts/seed.mjs b/scripts/seed.mjs index ecfc738..c2210ce 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -193,6 +193,24 @@ try { } console.log(` ✓ captured ${preservedPurchaseLines.length} purchase-line allocation(s) to preserve`); +// Capture BEFORE deleting assets. Site history and sticker QR values are owned by +// the app, not assets.json. Keep explicit empty values too: clearing a site is a +// deliberate manual observation, not permission to restore an old sheet value. +// Sites and plant_site_observations are not wiped; their ids remain valid. +const SITES_COLL = "sites"; +const preservedAssetByCode = new Map(); +const preservedAssetByName = new Map(); +const assetSiteFields = ["site", "site_label", "previous_site", "previous_site_label", "site_observed_on", "previous_site_observed_on", "site_source", "qr_code"]; +for (const asset of await pb.collection(ASSETS_COLL).getFullList()) { + const kept = {}; + for (const field of assetSiteFields) { + if (Object.hasOwn(asset, field)) kept[field] = asset[field]; + } + if (asset.code) preservedAssetByCode.set(asset.code, kept); + if (asset.name) preservedAssetByName.set(String(asset.name).trim().toLowerCase(), kept); +} +console.log(` ✓ captured ${preservedAssetByCode.size} asset site histories and QR values before wipe`); + // Wipe existing rows so we can re-seed cleanly. // // `operators` and `mechanics` are DELIBERATELY NOT in this list either. A person can be @@ -264,34 +282,8 @@ function normalizeName(n) { } // === Insert assets === -// Site assignments are preserved across a re-seed. Before the assets are wiped -// we snapshot the current code→site mapping, because the new rows get fresh -// ids and a name/code lookup is the only stable key across the wipe. -const SITES_COLL = "sites"; -let existingSiteByCode = new Map(); -let existingSiteByName = new Map(); -// QR text typed into the Plant Register is entered by hand and exists nowhere on disk. -const existingQrByCode = new Map(); // Vehicle sticker numbers from the client's sheet. A value typed in the app wins; this fills the rest. const sheetQrByCode = JSON.parse(readFileSync(resolve(DATA_DIR, "asset-qr.json"), "utf8")); -try { - const siteIdToName = new Map(); - for (const s of await pb.collection(SITES_COLL).getFullList()) { - siteIdToName.set(s.id, s.name); - } - for (const a of await pb.collection(ASSETS_COLL).getFullList()) { - if (a.code && a.qr_code) existingQrByCode.set(a.code, a.qr_code); - const siteName = a.site ? siteIdToName.get(a.site) : null; - if (!siteName) continue; - if (a.code) existingSiteByCode.set(a.code, siteName); - existingSiteByName.set(normalizeName(a.name), siteName); - } - if (existingSiteByCode.size || existingSiteByName.size) { - console.log(` · preserving site assignments for ${existingSiteByCode.size + existingSiteByName.size} asset lookups`); - } -} catch { - // `sites` is optional — a fresh install may not have run the setup script. -} let siteIdByName = new Map(); try { @@ -309,11 +301,10 @@ let ok = 0; const seedErrors = []; for (const a of assets) { try { - // `site` in assets.json wins; otherwise fall back to whatever was assigned - // before the wipe. An unrecognised name is a loud failure, not a silent - // unassignment — a dropped site link is exactly the kind of quiet data - // loss this seed is meant to surface. - const wantedSite = a.site || existingSiteByCode.get(a.code) || existingSiteByName.get(normalizeName(a.name)) || null; + // An existing app assignment (including an explicit clearing) wins. Only a + // first seed uses the sheet's site; unknown sheet names are loud failures. + const preservedAsset = preservedAssetByCode.get(a.code) ?? preservedAssetByName.get(String(a.name).trim().toLowerCase()); + const wantedSite = preservedAsset && Object.hasOwn(preservedAsset, "site") ? null : (a.site || null); let siteId = ""; if (wantedSite) { siteId = siteIdByName.get(normalizeName(wantedSite)) || ""; @@ -335,7 +326,8 @@ for (const a of assets) { on_operator_sheet: operatorSheetCodes.has(a.code), is_active: a.is_active !== false, // default true; only "AEB Plant Versekering" is false site: siteId, - qr_code: existingQrByCode.get(a.code) || sheetQrByCode[a.code] || "", + qr_code: sheetQrByCode[a.code] || "", + ...preservedAsset, // Plant Map — falls back to the shared placeholder pin until the // on-site scan sheet supplies a real coordinate for this machine. latitude: a.latitude ?? DEFAULT_GEO.latitude, diff --git a/scripts/setup-plant-site-history.mjs b/scripts/setup-plant-site-history.mjs new file mode 100644 index 0000000..f691a0e --- /dev/null +++ b/scripts/setup-plant-site-history.mjs @@ -0,0 +1,25 @@ +// Additive only. Observations use stable QRs rather than disposable assets relations. +import fs from 'node:fs'; +import PocketBase from 'pocketbase'; +try { process.loadEnvFile(new URL('../.env.local',import.meta.url)); } catch (error) { if(error.code!=='ENOENT')throw error; } +const pb=new PocketBase(process.env.NEXT_PUBLIC_PB_URL);pb.autoCancellation(false); +await pb.collection('_superusers').authWithPassword(process.env.PB_ADMIN,process.env.PB_PASS); +const [assets,sites]=await Promise.all([pb.collections.getOne('assets'),pb.collections.getOne('sites')]); +const out=process.argv.indexOf('--backup'); +if(out>=0){if(!process.argv[out+1])throw Error('Supply a backup path.');fs.writeFileSync(process.argv[out+1],JSON.stringify({at:new Date().toISOString(),assetsSchema:assets,sitesSchema:sites,assets:await pb.collection('assets').getFullList(),sites:await pb.collection('sites').getFullList()},null,2),{flag:'wx'});} +const desired=[ + ...['site_label','previous_site_label','site_observed_on','previous_site_observed_on','site_source'].map(name=>({name,type:'text'})), + {name:'previous_site',type:'relation',collectionId:sites.id,maxSelect:1,cascadeDelete:false}, +]; +for(const field of desired){const prior=assets.fields.find(f=>f.name===field.name);if(prior&&(prior.type!==field.type||field.collectionId&&prior.collectionId!==field.collectionId))throw Error('Incompatible existing field '+field.name);} +const additions=desired.filter(f=>!assets.fields.some(a=>a.name===f.name)); +if(additions.length)await pb.collections.update(assets.id,{fields:[...assets.fields,...additions]}); +async function collection(name,fields,indexes=[]){let found;try{found=await pb.collections.getOne(name);}catch(error){if(error.status!==404)throw error;} + if(!found)await pb.collections.create({name,type:'base',listRule:null,viewRule:null,createRule:null,updateRule:null,deleteRule:null,fields,indexes}); + else {const add=fields.filter(f=>!found.fields.some(a=>a.name===f.name));const ix=indexes.filter(x=>!found.indexes.includes(x));if(add.length||ix.length)await pb.collections.update(found.id,{fields:[...found.fields,...add],indexes:[...found.indexes,...ix]});}} +await collection('plant_site_observations',[ + ...['vehicle_qr','asset_code','site_name','observed_on','source','source_file','source_hash'].map(name=>({name,type:'text'})), + {name:'created',type:'autodate',onCreate:true,onUpdate:false},{name:'updated',type:'autodate',onCreate:true,onUpdate:true}, +],['CREATE INDEX idx_plant_site_qr ON plant_site_observations (vehicle_qr)']); +await collection('plant_site_sync_locks',['owner','started_at'].map(name=>({name,type:'text'}))); +console.log(JSON.stringify({ok:true,assetFieldsAdded:additions.map(f=>f.name),collections:['plant_site_observations','plant_site_sync_locks']}));