mega-plant/lib/sites.ts
David 293276ccce Sites: auto-allocate container lines to the container the site hires
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.

The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.

A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.

Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:32:12 +02:00

120 lines
No EOL
4.9 KiB
TypeScript

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<Site[]> {
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<string, { id: string; invoiceNo: string; invoiceDate: string; totalIncl: number }>();
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<string> {
const taken = new Set<string>();
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
);
}