mega-plant/lib/sites.ts
David ad4ac9173b Invoices: one line per charge, and fold an abbreviated site
PHI 179 was refused: "Machine lines total R5265 but the invoice subtotal is
R13730". Three separate faults, each of which only this invoice exposed.

1. The parser read ONE line per machine. A machine hired over more than one
   period is printed as one heading followed by a repeating block per charge,
   and PHI 179 is a single CT01 crane truck billed on 4, 16 and 18 Sep at
   R5 265 + R3 200 + R5 265. The parser only ever looked at the first block,
   so two thirds of the invoice went missing and the arithmetic check — which
   did its job — rejected it. extractLines() now walks y-banded rows and emits
   one line per money row, all carrying the machine's code. A machine billed
   once still yields exactly one line, which is what PHI 168/169 do.

   The banding matters: pdfjs splits one printed row across two y positions
   (the TOTAL sits ~2pt above the rest), so the fixed +-12pt window the old
   code used was never a row, it was a coincidence.

2. siteFromDescription required the description to START with "Hire Plant" or
   "Plant Hire". PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" — it leads
   with the machine type — so the regex returned "" and the invoice linked to
   no site at all. The month/year pair is the part every description shares,
   so that is the anchor now.

3. Even read correctly the site is "Cosmo", where every other invoice says
   "Cosmo City". Sites are DERIVED from the invoice wording, so saving the
   abbreviation would create a second "Cosmo" and split this customer across
   two rows on /sites. resolveSiteName() folds a shorter whole-word prefix
   onto a site already in the register, one direction only, so a genuinely new
   site is still recorded as new.

Also: a problem invoice stored its PDF with no way to open it. The inbox
reported the failure but not the document behind it, and /api/invoices/:id/
document only serves imported invoices — the one case you most want to inspect.
New GET /api/invoices/intake/:id/document, plus a PDF link on each inbox row.

Verified: PHI 179 reconciles in full (R13 730 + R2 059.50 VAT = R15 789.50,
three lines, each balancing). PHI 168 and PHI 169 still reproduce their hand
extraction field for field, including line count. 40 tests, typecheck clean.
2026-10-06 11:25:10 +02:00

264 lines
11 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 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 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),
};
})
.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;
}