import type { Asset, BookedLine, Invoice, Site } from "./data"; import { isInternalParty } from "./invoice-party"; // A line whose invoice is missing cannot be classified, so it must not be counted as // internal by accident. An invoice with no debtor is never internal — see // lib/invoice-party.ts — so an empty placeholder counts as external. const EMPTY_INVOICE = { debtor: "", debtor_no: "" } as Pick; // 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. // // ONLY INTERNAL invoices create sites. An external invoice is billed by a third party and // its description text is not a place name — "Crane Truck Hire 17 September 2026 Move // containers" yielded a site literally called "Move containers", which then appeared on // /sites and in the plant register's site filter. The name an external invoice carries is // its supplier, which is not a site, so it is never registered as one. See // lib/invoice-party-policy.json for how the two halves are told apart. // // 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. A // 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 .filter((i) => isInternalParty(i)) .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 SiteInvoiceRef = { id: string; invoiceNo: string; invoiceDate: string; periodFrom: string; periodTo: string; /** This site's share of the invoice — the sum of the site's lines, not the header * total. Equal to the header total today, because an invoice carries one site, but * summing lines keeps it right if an invoice ever spans two. */ totalIncl: number; hasPdf: boolean; }; /** One month of billing for a site. A month holds one or more invoices. */ export type SiteMonth = { key: string; // "2026-09" year: number; month: number; // 1-12 totalExcl: number; vat: number; totalIncl: number; invoices: SiteInvoiceRef[]; }; 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 }[]; /** The calendar year the `months` series covers, so the chart is never ambiguous. */ year: number; /** 12 slots of billed amount incl VAT. null = no invoice that month, which is * different from a billed zero. Same convention as IncomeRow.months. */ months: (number | null)[]; /** Only the months that actually have an invoice, ascending. Drives the expanded * panel, so a month with no billing is simply absent rather than a zero row. */ monthly: SiteMonth[]; machineCount: number; lineCount: number; /** True once the site has map coordinates. Carried on the summary so the table — * a client component — can render the marker without also receiving the site list. */ pinned: boolean; /** Lines with neither a plant item nor a site — the work still outstanding. */ unbookedCount: number; unbookedAmount: number; /** The outstanding lines split by which half of the register they live on. The site's * "assign them" link has to pick a page, and the register is split in two — a site * whose only unbooked lines are on external invoices must be sent to the external * page, or the link lands on a page that does not contain them. */ unbookedInternalCount: number; unbookedExternalCount: number; }; /** The year the chart should show: the most recent one any invoice was booked into. * Scrolling forward a year re-bases every series without a schema change. */ export function latestInvoiceYear(invoices: Invoice[]): number { return invoices.reduce((y, i) => (i.booked_year > y ? i.booked_year : y), new Date().getUTCFullYear()); } // Which month an invoice is billed in. // // `booked_month`/`booked_year` are already written by the seed from the month of // `period_to` (scripts/seed.mjs), which is the same rule that posts the income into // monthly_income. Reusing that field is what keeps a site's month here equal to the // same month on /income — a site chart that disagreed with the income screen would be // worse than no chart. The date fallback only covers a record imported before those // fields existed. function invoiceMonth(inv: Invoice): { year: number; month: number } { if (inv.booked_month >= 1 && inv.booked_month <= 12 && inv.booked_year) { return { year: inv.booked_year, month: inv.booked_month }; } const d = inv.period_to || inv.invoice_date; if (d) { const dt = new Date(d); if (!Number.isNaN(dt.getTime())) return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1 }; } return { year: 0, month: 0 }; } // Per-site revenue and VAT, derived by walking invoice lines through their invoice. // // ONLY INTERNAL invoices count towards a site. An external invoice's "site" is its // supplier, not a place, so letting it in here is what put "Move containers" on /sites as // if it were a yard. Its income is not lost — it is booked to a machine and still shows on // /income and /charts; it simply is not a site. export function buildSiteSummaries( sites: Site[], invoices: Invoice[], lines: BookedLine[], assets: Asset[], ): SiteSummary[] { const invById = new Map(invoices.map((i) => [i.id, i])); const year = latestInvoiceYear(invoices); return sites .map((site) => { const siteLines = lines.filter( (l) => invById.get(l.invoice)?.site === site.name && isInternalParty(invById.get(l.invoice)!), ); 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 }); } // Bucket the same lines by billing month. One invoice contributes several lines to // a site (PHI 166 has five), so an invoice is listed ONCE per month and its amount // accumulates across those lines — otherwise a site would show a card per line. const buckets = new Map(); const refsByMonth = new Map>(); for (const l of siteLines) { const inv = invById.get(l.invoice); if (!inv) continue; const m = invoiceMonth(inv); if (!m.year || !m.month) continue; const key = `${m.year}-${String(m.month).padStart(2, "0")}`; let bucket = buckets.get(key); if (!bucket) { bucket = { key, year: m.year, month: m.month, totalExcl: 0, vat: 0, totalIncl: 0, invoices: [] }; buckets.set(key, bucket); refsByMonth.set(key, new Map()); } bucket.totalExcl += l.amount_excl || 0; bucket.vat += l.vat || 0; bucket.totalIncl += l.total_incl || 0; const refs = refsByMonth.get(key)!; const seen = refs.get(inv.id); if (seen) { seen.totalIncl += l.total_incl || 0; } else { refs.set(inv.id, { id: inv.id, invoiceNo: inv.invoice_no, invoiceDate: inv.invoice_date, periodFrom: inv.period_from, periodTo: inv.period_to, totalIncl: l.total_incl || 0, hasPdf: Boolean(inv.source_pdf), }); } } const monthly = Array.from(buckets.values()) .sort((a, b) => a.key.localeCompare(b.key)) .map((b) => ({ ...b, invoices: Array.from(refsByMonth.get(b.key)!.values()).sort((x, y) => x.invoiceNo.localeCompare(y.invoiceNo)), })); // 12-slot series for the sparkline. Months from OTHER years are dropped rather // than folded in — a Dec-2025 invoice must not paint a point on Jan. const months: (number | null)[] = Array(12).fill(null); for (const b of monthly) if (b.year === year) months[b.month - 1] = b.totalIncl; 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)), year, months, monthly, machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size, lineCount: siteLines.length, pinned: Boolean(site.latitude), unbookedCount: unbooked.length, unbookedAmount: unbooked.reduce((s, l) => s + (l.total_incl || 0), 0), unbookedInternalCount: unbooked.filter((l) => isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length, unbookedExternalCount: unbooked.filter((l) => !isInternalParty(invById.get(l.invoice) ?? EMPTY_INVOICE)).length, }; }) .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 ); } /** * Fold the site name an invoice prints onto the name the register already uses. * * The supplier abbreviates: PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" for the * site every other invoice calls "Cosmo City". Taking that literally would create a * SECOND site called "Cosmo" — `syncSitesFromInvoices` derives sites from the invoice * wording — and the same customer would then split across two places on /sites, with * neither row showing the whole picture. * * Only an unambiguous whole-word match folds, and only in ONE direction: the invoice's * name must be shorter and be a whole-word prefix of a site already in the register. * That is what an abbreviation looks like. The reverse — folding "Cosmo City Extension" * onto "Cosmo City" — would silently merge two genuinely different places, so it is not * done; an unknown name keeps the invoice's own wording and a new site is still recorded * as new. */ export function resolveSiteName(raw: string, known: string[]): string { const t = (raw || "").trim(); if (!t) return ""; const low = t.toLowerCase(); for (const k of known) { const kk = (k || "").trim().toLowerCase(); if (!kk) continue; if (kk === low) return k; if (kk.startsWith(`${low} `)) return k; } return t; }