import type { Asset, BookedLine, Invoice, Site } from "./data"; // Sites are DERIVED from the invoices: each distinct invoice site becomes a site. // They are not typed in by hand, so this is the only place that creates them. // // Existing records are never deleted and never overwritten — a site may carry notes // and may have lines booked to it, so removing one would orphan that history. An // invoice site that disappears simply stops appearing in new invoices. export async function syncSitesFromInvoices(pb: any, invoices: Invoice[]): Promise { const existing = (await pb.collection("sites").getFullList()) as Site[]; const byName = new Map(existing.map((s: Site) => [(s.name || "").toLowerCase(), s])); const wanted = [...new Set(invoices.map((i) => (i.site || "").trim()).filter(Boolean))]; for (const name of wanted) { if (byName.has(name.toLowerCase())) continue; try { const created = await pb.collection("sites").create({ name, is_active: true }); byName.set(name.toLowerCase(), created as Site); } catch { // A concurrent upload may have created it first; the catch is a no-op. } } return byName.size ? Array.from(byName.values()) : existing; } export type SiteSummary = { id: string; name: string; /** Income, ex VAT — what actually lands in the P&L. */ revenueExcl: number; vat: number; /** What the debtors were billed in total. */ revenueIncl: number; invoices: { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }[]; machineCount: number; lineCount: number; /** Lines with neither a plant item nor a site — the work still outstanding. */ unbookedCount: number; unbookedAmount: number; }; // Per-site revenue and VAT, derived by walking invoice lines through their invoice. export function buildSiteSummaries( sites: Site[], invoices: Invoice[], lines: BookedLine[], assets: Asset[], ): SiteSummary[] { const invById = new Map(invoices.map((i) => [i.id, i])); const assetById = new Map(assets.map((a) => [a.id, a])); return sites .map((site) => { const siteLines = lines.filter((l) => invById.get(l.invoice)?.site === site.name); const revExcl = siteLines.reduce((s, l) => s + (l.amount_excl || 0), 0); const vat = siteLines.reduce((s, l) => s + (l.vat || 0), 0); const incl = siteLines.reduce((s, l) => s + (l.total_incl || 0), 0); const invMap = new Map(); for (const l of siteLines) { const inv = invById.get(l.invoice); if (inv) invMap.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, totalIncl: inv.total_incl }); } const unbooked = siteLines.filter((l) => !l.isBooked && !l.booked_site); return { id: site.id, name: site.name, revenueExcl: revExcl, vat, revenueIncl: incl, invoices: Array.from(invMap.values()).sort((a, b) => b.invoiceDate.localeCompare(a.invoiceDate)), machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size, lineCount: siteLines.length, unbookedCount: unbooked.length, unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0), }; }) .filter((s) => s.invoices.length > 0 || s.lineCount > 0) .sort((a, b) => b.revenueIncl - a.revenueIncl); } // Sites already taken by another line that has no plant item. // One container per site: once a site holds a container, the next container's // picker must not offer it, so the outstanding containers spread across sites // instead of piling onto the first one. export function takenSiteIdsFor(lines: BookedLine[], exceptLineId: string): Set { const taken = new Set(); for (const l of lines) { if (l.id === exceptLineId) continue; if (l.booked_site && !l.booked_asset) taken.add(l.booked_site); } return taken; } // Auto-allocation for a line that matches no plant item. // // The invoices print a generic code for hire containers ("MPLANT05 Storage Container") // that the plant register does not carry. Each site records the container it hires, and // the code the invoice uses for it. So a container line arriving on an invoice for a site // that has a matching container is booked to that container rather than left stuck. // // The lookup is against THIS invoice's site only — two sites can both hire // "MPLANT05" units, and each line must go to the unit actually on its own site. export function findSiteContainer( sites: Site[], siteName: string, machineCode: string, ): Site | null { const wanted = (machineCode || "").trim().toLowerCase(); if (!wanted || !siteName) return null; return ( sites.find( (s) => (s.name || "").toLowerCase() === (siteName || "").toLowerCase() && (s.container_code || "").trim().toLowerCase() === wanted && Boolean(s.container_asset), ) ?? null ); }