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.
This commit is contained in:
parent
696c157e2e
commit
1429c8087b
21 changed files with 924 additions and 70 deletions
|
|
@ -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 (
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="min-w-0 p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
|
|
@ -26,6 +27,7 @@ export default async function PlantMapPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<FuelSiteImport />
|
||||
<PlantMapLoader
|
||||
assets={assets.map((a) => ({
|
||||
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,
|
||||
}))}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="p-8 max-w-[1400px]">
|
||||
<div className="min-w-0 p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
|
|
@ -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. */}
|
||||
<FuelSiteImport />
|
||||
<PlantRegisterTable
|
||||
sites={sites.map((s) => ({ 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 || "",
|
||||
}))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
app/api/plant/site-import/route.ts
Normal file
18
app/api/plant/site-import/route.ts
Normal file
|
|
@ -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});}
|
||||
}
|
||||
94
components/FuelSiteImport.tsx
Normal file
94
components/FuelSiteImport.tsx
Normal file
|
|
@ -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<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<Preview | null>(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 (
|
||||
<section className="min-w-0 mb-5">
|
||||
<button type="button" onClick={() => { setOpen(v => !v); setResult(""); }} aria-expanded={open} aria-controls={`${inputId}-panel`} className={`${control} bg-[#30638E] text-white hover:bg-[#003D5B]`}>
|
||||
<Upload className="h-4 w-4" /> Upload fuel log
|
||||
</button>
|
||||
{result && <p role="status" className="mt-3 text-sm text-[#00798C]">{result}</p>}
|
||||
{open && <div id={`${inputId}-panel`} className="mt-3 min-w-0 rounded-2xl border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-surface">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0"><h2 className="text-sm font-semibold text-[#003D5B] dark:text-white">Update vehicle sites from a fuel log</h2><p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Choose the exported CSV, review the latest logged site for each matched QR, then apply. Previous site means the last different site recorded.</p></div>
|
||||
<button type="button" disabled={!!busy} onClick={() => { reset(); setOpen(false); }} aria-label="Close fuel log import" className={`${control} min-w-[44px] shrink-0 text-gray-500 hover:bg-gray-100 dark:hover:bg-white/5`}><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<div className="mt-4 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="min-w-0 flex-1"><label htmlFor={inputId} className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">Fuel log CSV</label><input ref={fileInput} id={inputId} type="file" accept=".csv,text/csv" disabled={!!busy} onChange={e => { 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" /></div>
|
||||
<button type="button" disabled={!file || !!busy} onClick={() => submit("preview")} className={`${control} bg-[#30638E] text-white`}>{busy === "preview" && <Loader2 className="h-4 w-4 animate-spin" />}Preview changes</button>
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-3 break-words text-sm text-[#D1495B]">{error}</p>}
|
||||
{preview && <div className="mt-4 min-w-0 space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{preview.totalRows} rows · {preview.matchedVehicles} matched vehicles · {preview.observationCount} observations · {preview.ignoredRows} ignored rows</p>
|
||||
{preview.newSites.length > 0 && <p className="break-words text-xs text-gray-600 dark:text-gray-300">New sites: {preview.newSites.join(", ")}. Add their coordinates in the map's site panel after import.</p>}
|
||||
{(preview.unknownQrs.length > 0 || preview.issues.length > 0) && <div className="rounded-lg border border-[#EDAE49]/40 bg-[#EDAE49]/10 p-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
{preview.unknownQrs.length > 0 && <details><summary className="flex min-h-[44px] cursor-pointer items-center font-medium">{preview.unknownQrs.length} unmatched QR codes — excluded</summary><ul className="space-y-1 break-words">{preview.unknownQrs.map((q, i) => <li key={`${q.qr}-${i}`}>{q.qr || "Empty QR"}{q.name ? ` · ${q.name}` : ""} · {q.rows} rows</li>)}</ul></details>}
|
||||
{preview.issues.length > 0 && <details><summary className="flex min-h-[44px] cursor-pointer items-center font-medium">{preview.issues.length} issues to review</summary><ul className="space-y-1 break-words">{preview.issues.map((issue, i) => <li key={i}>{issue.qr && `${issue.qr}: `}{issue.reason}</li>)}</ul></details>}
|
||||
</div>}
|
||||
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
||||
{preview.changes.map(change => <article key={change.assetId} className="rounded-lg border border-gray-100 p-3 dark:border-white/10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm"><span className="min-w-0 max-w-full break-all font-semibold text-[#003D5B] dark:text-white">{change.code || change.qr}</span><span className="max-w-full break-words text-xs text-gray-500 dark:text-gray-400">{change.status.replaceAll("-", " ").replaceAll("_", " ")}</span></div>
|
||||
<dl className="mt-2 grid min-w-0 grid-cols-1 gap-2 text-xs sm:grid-cols-3 [&>div]:min-w-0"><div><dt className="text-gray-500 dark:text-gray-400">Current site</dt><dd className="break-words font-medium text-[#003D5B] dark:text-white">{change.currentSite || "Unknown"}</dd></div><div><dt className="text-gray-500 dark:text-gray-400">Previous site</dt><dd className="break-words">{change.previousSite || "Unknown"}</dd>{change.previousSeen && <dd className="text-gray-500 dark:text-gray-400">{siteSeenDate(change.previousSeen)}</dd>}</div><div><dt className="text-gray-500 dark:text-gray-400">Last seen</dt><dd>{siteSeenDate(change.lastSeen)}</dd></div></dl>
|
||||
{change.needsCoordinates && <p className="mt-2 text-xs text-[#30638E] dark:text-gray-300">Site coordinates needed to show this vehicle on the map.</p>}
|
||||
</article>)}
|
||||
{preview.changes.length === 0 && <p className="text-sm text-gray-500">No matched vehicles to update.</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2"><button type="button" disabled={!preview.canApply || !!busy} onClick={() => submit("apply")} className={`${control} bg-[#00798C] text-white`}>{busy === "apply" && <Loader2 className="h-4 w-4 animate-spin" />}Apply fuel log</button></div>
|
||||
{!preview.canApply && <p className="text-xs text-gray-600 dark:text-gray-300">{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."}</p>}
|
||||
</div>}
|
||||
</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(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]")}
|
||||
>
|
||||
<TileLayer url={STREET_TILES.url} attribution={STREET_TILES.attribution} maxNativeZoom={19} maxZoom={19} />
|
||||
<MapController focus={focus} points={points} fitKey={fitKey} />
|
||||
<MapController focus={focus} points={points} fitKey={fitKey} onPopupChange={setPopupOpen} />
|
||||
{placingId && <MapClickCapture onPick={(lat, lng) => saveCoords(placingId, lat, lng)} disabled={saving} />}
|
||||
<Marker position={[HEAD_OFFICE.latitude, HEAD_OFFICE.longitude]} icon={waypointIcon(true)} title="Head office" zIndexOffset={1000}>
|
||||
<Popup><strong>Head office</strong><p>Default location for unassigned machines.</p></Popup>
|
||||
|
|
@ -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"}
|
||||
<br />{a.latitude?.toFixed(5)}, {a.longitude?.toFixed(5)}
|
||||
</div>
|
||||
<div className="mt-3 border-t border-gray-200 pt-3 dark:[&_dt]:text-[#003D5B] dark:[&_dd]:text-gray-600 dark:[&_span]:text-gray-500"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} /></div>
|
||||
{a.qr_code && <p className="text-xs text-gray-500">QR: {a.qr_code}</p>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
|
@ -269,10 +283,10 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
|
|||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
|
||||
<button type="button" onClick={() => { setFocus(null); setFitKey(k => k + 1); }}
|
||||
{!popupOpen && <button type="button" onClick={() => { setFocus(null); setFitKey(k => k + 1); }}
|
||||
className="absolute top-3 right-3 z-[500] min-h-[44px] px-3 rounded-lg bg-white dark:bg-surface text-sm font-medium shadow text-[#003D5B] dark:text-white">
|
||||
Fit all
|
||||
</button>
|
||||
</button>}
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 bg-white dark:bg-surface border-t border-gray-200 dark:border-white/10 px-3 py-2 text-xs">
|
||||
<span className="flex items-center gap-2"><span className="w-3 h-3 rounded-sm bg-[#EDAE49]" /><span className="text-gray-600 dark:text-gray-300">Site</span></span>
|
||||
|
|
@ -321,10 +335,16 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
|
|||
<span className="text-xs shrink-0 text-gray-500 dark:text-gray-400">{assetsState.filter(a => a.site === site.id).length} assigned</span>
|
||||
</button>
|
||||
))}
|
||||
{sites.length > pinnedSites.length && (
|
||||
<a href="/sites" className="flex items-center min-h-[44px] text-xs text-[#30638E] dark:text-gray-300 underline underline-offset-4">
|
||||
{sites.length - pinnedSites.length} sites still need coordinates
|
||||
</a>
|
||||
{unpinnedSites.length > 0 && (
|
||||
<details className="mt-2 min-w-0 border-t border-gray-100 dark:border-white/10">
|
||||
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">{unpinnedSites.length} sites need coordinates</summary>
|
||||
<div className="min-w-0 space-y-4 pt-2 [&_input]:min-w-0 [&_.flex-1]:min-w-0">
|
||||
{unpinnedSites.map(site => <div key={site.id} className="min-w-0">
|
||||
<h3 className="break-words text-sm font-medium text-[#003D5B] dark:text-white">{site.name}</h3>
|
||||
<SiteCoordinateEditor siteId={site.id} name={site.name} latitude={site.latitude} longitude={site.longitude} />
|
||||
</div>)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Assigned machines follow their site's pin. These locations are based on assignments, not live GPS.</p>
|
||||
</div>
|
||||
|
|
@ -399,11 +419,12 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
|
|||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 transition",
|
||||
"p-2 transition",
|
||||
isPlacing ? "bg-[#EDAE49]/10" : "hover:bg-gray-50 dark:hover:bg-white/[0.02]",
|
||||
!a.is_active && !isPlacing && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!on}
|
||||
|
|
@ -444,7 +465,7 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
|
|||
onClick={() => setPlacingId(isPlacing ? null : a.id)}
|
||||
aria-label={`Place ${a.name} on the map`}
|
||||
disabled={Boolean(a.site) || saving}
|
||||
title={a.site ? "Edit the assigned site coordinates from Sites" : undefined}
|
||||
title={a.site ? "Add or edit coordinates in the site panel above" : undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-lg transition shrink-0 disabled:opacity-40",
|
||||
isPlacing
|
||||
|
|
@ -466,6 +487,13 @@ export function PlantMap({ assets, sites }: { assets: MapAsset[]; sites: MapSite
|
|||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<details className="mt-1 px-1">
|
||||
<summary className="flex min-h-[44px] cursor-pointer items-center text-xs font-medium text-[#30638E] dark:text-gray-300">Vehicle site details</summary>
|
||||
<div className="pb-2 pl-1"><VehicleSiteHistory asset={a} currentSite={a.siteName} previousSite={sites.find(site => site.id === a.previous_site)?.name} />
|
||||
<p className="mt-2 break-words text-xs text-gray-500 dark:text-gray-400">QR: {a.qr_code || a.code || "Not recorded"}</p>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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<Asset[]>(initialAssets);
|
||||
useEffect(() => setAssets(initialAssets), [initialAssets]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("All");
|
||||
const [siteFilter, setSiteFilter] = useState<string>("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") => (
|
||||
<th
|
||||
onClick={() => {
|
||||
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" : ""}`}
|
||||
>
|
||||
<button type="button" onClick={() => {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}}
|
||||
className={`text-${align} py-3 px-3 font-medium cursor-pointer select-none hover:text-[#003D5B] dark:hover:text-white transition`}
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1 ${align === "right" ? "justify-end" : ""}`}>
|
||||
}} className={`inline-flex min-h-[44px] min-w-[44px] w-full items-center gap-1 text-${align} hover:text-[#003D5B] dark:hover:text-white transition ${align === "right" ? "justify-end" : "justify-start"}`}>
|
||||
{label}
|
||||
<ArrowUpDown className={`w-3 h-3 ${sortKey === key ? "text-[#30638E]" : "text-gray-300"}`} />
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border p-6 shadow-sm">
|
||||
<section className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border min-w-0 p-4 sm:p-6 shadow-sm">
|
||||
{/* Header / search / edit button */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
|
|
@ -455,8 +464,8 @@ export function PlantRegisterTable({
|
|||
{filtered.length} of {assets.length} items · sorted by {sortKey} {sortDir}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<div className="flex min-w-0 w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
value={query}
|
||||
|
|
@ -476,7 +485,7 @@ export function PlantRegisterTable({
|
|||
<span>Edit</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex max-w-full flex-wrap items-center gap-2">
|
||||
{/* 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} <span className="ml-1 text-[10px] opacity-60">({count})</span>
|
||||
{t} <span className="ml-1 text-xs opacity-60">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -676,7 +685,7 @@ export function PlantRegisterTable({
|
|||
{headerCell("code", "Code")}
|
||||
{headerCell("name", "Name")}
|
||||
{headerCell("type", "Type")}
|
||||
{headerCell("site", "Site")}
|
||||
{headerCell("site", "Site history")}
|
||||
<th className="text-left py-3 px-3 font-medium">QR code</th>
|
||||
<th className="text-center py-3 px-3 font-medium">Status</th>
|
||||
{editing && <th className="text-center py-3 px-3 font-medium">Delete</th>}
|
||||
|
|
@ -821,13 +830,10 @@ export function PlantRegisterTable({
|
|||
))}
|
||||
</select>
|
||||
</>
|
||||
) : effectiveSite(a) ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-gray-700 dark:text-gray-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#00798C] shrink-0" />
|
||||
{siteName(effectiveSite(a)) || "Unknown site"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">—</span>
|
||||
<div className="min-w-[210px] max-w-[300px]">
|
||||
<VehicleSiteHistory asset={a} currentSite={siteName(effectiveSite(a))} previousSite={siteName(a.previous_site || "")} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
|
|
|
|||
28
components/VehicleSiteHistory.tsx
Normal file
28
components/VehicleSiteHistory.tsx
Normal file
|
|
@ -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 (
|
||||
<dl className="space-y-1.5 text-xs leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
<div><dt className="font-medium text-[#003D5B] dark:text-white">Current site</dt><dd className="break-words">{asset.site_label || currentSite || "Unassigned"}</dd></div>
|
||||
<div><dt className="font-medium text-[#003D5B] dark:text-white">Previous site</dt><dd className="break-words">{asset.previous_site_label || previousSite || "Unknown — no earlier different site recorded"}{asset.previous_site_observed_on && <span className="block text-gray-500 dark:text-gray-400">{siteSeenDate(asset.previous_site_observed_on)}</span>}</dd></div>
|
||||
<div><dt className="font-medium text-[#003D5B] dark:text-white">Last seen</dt><dd>{siteSeenDate(asset.site_observed_on)} · {source}</dd></div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
85
docs/plant-site-import.md
Normal file
85
docs/plant-site-import.md
Normal file
|
|
@ -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
|
||||
```
|
||||
25
docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md
Normal file
25
docs/superpowers/plans/2026-11-04-plant-fuel-site-history.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import headOffice from "./plant-location-policy.json";
|
|||
|
||||
export const HEAD_OFFICE = headOffice;
|
||||
|
||||
export type MapAsset = Pick<Asset, "id" | "code" | "name" | "category" | "type" | "is_active" | "site" | "latitude" | "longitude">;
|
||||
export type MapAsset = Pick<Asset, "id" | "code" | "name" | "category" | "type" | "is_active" | "site" | "latitude" | "longitude" | "qr_code" | "site_label" | "previous_site" | "previous_site_label" | "site_observed_on" | "previous_site_observed_on" | "site_source">;
|
||||
export type MapSite = Pick<Site, "id" | "name" | "latitude" | "longitude">;
|
||||
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;
|
||||
|
|
|
|||
63
lib/plant-site-history.test.ts
Normal file
63
lib/plant-site-history.test.ts
Normal file
|
|
@ -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'));
|
||||
});
|
||||
88
lib/plant-site-history.ts
Normal file
88
lib/plant-site-history.ts
Normal file
|
|
@ -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, string>): 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<string, SiteObservation[]>();
|
||||
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<string, Set<string>>();
|
||||
for (const o of exact) { const t = new Date(o.observedOn).toISOString(); const namesAt = times.get(t) ?? new Set<string>(); 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 };
|
||||
}
|
||||
181
lib/plant-site-import.test.ts
Normal file
181
lib/plant-site-import.test.ts
Normal file
|
|
@ -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<string, Row[]> = {
|
||||
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<string, unknown>) { 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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<void>(resolve => { release = resolve; });
|
||||
let acquired!: () => void;
|
||||
const started = new Promise<void>(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);
|
||||
});
|
||||
150
lib/plant-site-import.ts
Normal file
150
lib/plant-site-import.ts
Normal file
|
|
@ -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<Event & RecordModel>()]);
|
||||
const byQr = new Map<string, RecordModel[]>();
|
||||
for (const a of assets) { const q = qrOf(a); if (q) byQr.set(q, [...(byQr.get(q) ?? []), a]); }
|
||||
const incoming = new Map<string, Event[]>(), unknown = new Map<string, {qr:string;name:string;rows:number}>();
|
||||
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<string>();
|
||||
let pendingObservations = 0, observationCount = 0;
|
||||
for (const [qr, rows] of incoming) {
|
||||
const asset = byQr.get(qr)![0], merged = new Map<string, Event>();
|
||||
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<T>(pb: PocketBase, work: () => Promise<T>): Promise<T> {
|
||||
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<string, unknown>) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
7
lib/plant-site-policy.json
Normal file
7
lib/plant-site-policy.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"aliases": {
|
||||
"Equestria Bastion Job": "Equestria 215",
|
||||
"Cosmo City / Lion Park": "Cosmo City"
|
||||
},
|
||||
"heldSiteNames": []
|
||||
}
|
||||
26
scripts/import-plant-sites.ts
Normal file
26
scripts/import-plant-sites.ts
Normal file
|
|
@ -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<string,string|boolean>={};
|
||||
for(let i=0;i<args.length;i++){const k=args[i];if(k==='--preview'||k==='--apply')options[k]=true;else if(['--file','--result','--expected-hash'].includes(k)&&args[i+1])options[k]=args[++i];else throw Error('Unknown option or missing value: '+k);}
|
||||
if(!options['--file']||!options['--result']||Boolean(options['--preview'])===Boolean(options['--apply']))throw Error('Supply --file, --result, and exactly one of --preview / --apply.');
|
||||
if(options['--apply']&&!/^[a-f0-9]{64}$/.test(String(options['--expected-hash']||'')))throw Error('Apply requires the preview --expected-hash.');
|
||||
try{process.loadEnvFile(new URL('../.env.local',import.meta.url));}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}
|
||||
const target=path.resolve(String(options['--result']));
|
||||
await fs.mkdir(path.dirname(target),{recursive:true});
|
||||
const handle=await fs.open(target,'wx');
|
||||
try{
|
||||
const source=path.resolve(String(options['--file']));const bytes=await fs.readFile(source);if(bytes.length>5*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;});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
25
scripts/setup-plant-site-history.mjs
Normal file
25
scripts/setup-plant-site-history.mjs
Normal file
|
|
@ -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']}));
|
||||
Loading…
Reference in a new issue