mega-plant/lib/sites.ts
David 6f66dff2a6 Split the invoice register into Internal and External Invoices
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.

Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.

lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.

The split is 16 internal / 3 external across the 19 invoices in the register.

Also fixes three things the split would otherwise break:

- Booking a line, importing a PDF and retrying an intake can all land on either
  side depending on the debtor, so the five write paths only revalidated
  /income/invoices and would have left the other page stale. They now call
  revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
  lib/invoice-routes.ts, which imports no next/* so a client component may import
  it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
  outstanding lines sit on external invoices would dead-end. SiteSummary now
  carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.

The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.

Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.

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 11:49:48 +02:00

278 lines
12 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.
//
// 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 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.
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);
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;
}