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.
67 lines
No EOL
3.2 KiB
TypeScript
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"]);
|
|
}); |