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.
299 lines
13 KiB
TypeScript
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;
|
|
}
|