mega-plant/lib/plant-map.ts
Dave 1429c8087b 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.
2026-11-05 00:18:24 +02:00

36 lines
2.2 KiB
TypeScript

import type { Asset, Site } from "./data";
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" | "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 & {
source: "site" | "missing-site" | "head-office" | "manual" | "unlocated";
siteName: string | null;
};
export function hasCoords(point: Coordinates): boolean {
const { latitude, longitude } = point;
return latitude != null && longitude != null && Number.isFinite(latitude) && Number.isFinite(longitude)
&& Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 && (latitude !== 0 || longitude !== 0);
}
// 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; 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;
return { latitude: asset.latitude, longitude: asset.longitude, source: atOffice ? "head-office" : "manual", siteName: null };
}