mega-plant/lib/sites.ts
David 80ca4788a2 Give external invoices a supplier name; keep them off the sites page
The name on an invoice means two different things. On an internal invoice it is a
SITE, transcribed from the invoice's own description. On an external invoice it is
the SUPPLIER - and the PDF does not print the supplier's company name at all, so
there is nothing to transcribe and a human has to type it.

`invoices.site` stays the single field; the column header follows the page (Site on
Internal, Supplier on External) and only the external one is editable, via
components/SupplierCell.tsx and PATCH /api/invoices/:id/site. It saves on blur or
Enter, never per keystroke, and an empty value clears the name so a stray
description fragment can be removed rather than displayed as a supplier.

Sites stop taking their names from external invoices, in both places:

- syncSitesFromInvoices only creates sites from internal invoices, so a job
  description can never be registered as a yard.
- buildSiteSummaries only counts internal invoice lines towards a site, which is
  what /sites and the dashboard's per-site chart read.

This is the "Move containers" case. PHI 192's description is "Crane Truck Hire 17
September 2026 Move containers", which had registered a site literally called
"Move containers" - a job, not a yard - on /sites, on the dashboard's per-site
chart and in the plant register's site filter. The record stays in the collection
(sites are never deleted) but is no longer surfaced; the count went 13 -> 12.
External income is not lost by this - it is booked to a machine and still appears on
/income and /charts. It is simply not a site.

The intake route stops resolving an external invoice's text against the site list,
which would have invented a site out of a description, and reports needsSupplier so
the import toast says the name is outstanding.

npm run seed now captures existing invoice names before it wipes the collection and
re-applies them where data/source does not specify one - the same data-loss trap the
uploaded PDFs already had. A JSON name always wins, so internal sites are unchanged.

Verified: typecheck clean, 45 tests pass (4 new, covering the "Move containers" case
directly), production build green, the new PATCH route 401s when anonymous, and
/sites plus both invoice pages pass the mobile checklist at 375x812 and 768x1024. The
supplier edit was exercised end to end and the test value reverted.

Still open: the three external invoices (PHI 178/186/192) have no supplier name yet
and none can be derived from the data. They show "Set supplier" until the client
supplies them.

Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 12:04:42 +02:00

299 lines
13 KiB
TypeScript

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<Invoice, "debtor" | "debtor_no">;
// 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<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
.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<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 });
}
// 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<string, SiteMonth>();
const refsByMonth = new Map<string, Map<string, SiteInvoiceRef>>();
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<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
);
}
/**
* 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;
}