A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.
The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.
A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.
Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
No EOL
4.9 KiB
TypeScript
120 lines
No EOL
4.9 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 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 }[];
|
|
machineCount: number;
|
|
lineCount: number;
|
|
/** Lines with neither a plant item nor a site — the work still outstanding. */
|
|
unbookedCount: number;
|
|
unbookedAmount: number;
|
|
};
|
|
|
|
// 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 assetById = new Map(assets.map((a) => [a.id, a]));
|
|
|
|
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 });
|
|
}
|
|
|
|
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)),
|
|
machineCount: new Set(siteLines.map((l) => l.booked_asset).filter(Boolean)).size,
|
|
lineCount: siteLines.length,
|
|
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
|
|
);
|
|
} |