mega-plant/lib/sites.test.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

67 lines
No EOL
3.2 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { buildSiteSummaries } from "./sites";
import type { Asset, BookedLine, Invoice, Site } from "./data";
const site = (name: string) => ({ id: `s-${name}`, name }) as Site;
// Internal = billed to Mega Infrastructure (09MIS001). External = a third-party debtor
// number, which is all three of PHI 178/186/192 actually carry.
const internal = (over: Partial<Invoice> = {}) =>
({ id: `inv-${Math.random()}`, invoice_no: "PHI 166", invoice_date: "2026-09-18",
debtor: "Mega Infrastructure", debtor_no: "09MIS001", site: "West Park",
total_excl: 100, total_incl: 115, vat: 15, period_to: "2026-09-18",
booked_month: 9, booked_year: 2026, ...over }) as Invoice;
const external = (over: Partial<Invoice> = {}) =>
({ ...internal(over), debtor: "", debtor_no: "09DTE001" }) as Invoice;
const line = (invoice: string, over: Partial<BookedLine> = {}) =>
({ id: `l-${Math.random()}`, invoice, amount_excl: 100, vat: 15, total_incl: 115,
booked_asset: "", booked_site: "", machine_code: "CT01", isBooked: true, ...over }) as BookedLine;
const summaries = (sites: Site[], invoices: Invoice[], lines: BookedLine[]) =>
buildSiteSummaries(sites, invoices, lines, [] as Asset[]);
test("an internal invoice's site is summarised with its money", () => {
const inv = internal();
const [row] = summaries([site("West Park")], [inv], [line(inv.id)]);
assert.ok(row, "West Park should have a row");
assert.equal(row.name, "West Park");
assert.equal(row.revenueIncl, 115);
assert.equal(row.lineCount, 1);
});
test("an external invoice does NOT put its name on the sites page", () => {
// The real case: PHI 192's description "Crane Truck Hire 17 September 2026 Move
// containers" produced a site literally called "Move containers". An external invoice
// is not billed to a place, so its name is the supplier and must not become a site row.
const inv = external({ invoice_no: "PHI 192", site: "Move containers" });
const rows = summaries([site("Move containers")], [inv], [line(inv.id)]);
assert.deepEqual(rows, [], "an external-only site must not appear on /sites");
});
test("a site with both internal and external lines keeps only the internal money", () => {
const internalInv = internal({ site: "Castle Gate" });
const externalInv = external({ invoice_no: "PHI 186", site: "Castle Gate" });
const rows = summaries(
[site("Castle Gate")],
[internalInv, externalInv],
[line(internalInv.id, { total_incl: 115 }), line(externalInv.id, { total_incl: 500 })],
);
assert.equal(rows.length, 1);
// 500 of external billing must not inflate the site's revenue.
assert.equal(rows[0].revenueIncl, 115);
assert.equal(rows[0].lineCount, 1);
});
test("the sites total cannot silently drop an internal site", () => {
// Guards the filter against over-reaching: internal sites must all survive.
const invoices = ["West Park", "Cosmo City", "The Hills"].map((s) => internal({ site: s }));
const rows = summaries(
["West Park", "Cosmo City", "The Hills"].map(site),
invoices,
invoices.map((i) => line(i.id)),
);
assert.deepEqual(rows.map((r) => r.name).sort(), ["Cosmo City", "The Hills", "West Park"]);
});