The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
No EOL
9.1 KiB
TypeScript
251 lines
No EOL
9.1 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { serverAuth } from "@/lib/pb";
|
|
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
|
|
import { syncSitesFromInvoices } from "@/lib/sites";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const MAX_BYTES = 20 * 1024 * 1024;
|
|
|
|
// GET /api/invoices/intake
|
|
// The problem-invoice inbox: every PDF offered to the app and what became of it.
|
|
// `?status=failed` narrows it to the ones that need attention.
|
|
export async function GET(req: Request) {
|
|
const url = new URL(req.url);
|
|
const status = url.searchParams.get("status");
|
|
try {
|
|
const pb = await serverAuth();
|
|
// PocketBase rejects an empty filter string, so only send one when filtering.
|
|
const opts: Record<string, unknown> = { sort: "-created" };
|
|
if (status) opts.filter = `status = "${status}"`;
|
|
const records = await pb.collection("invoice_intake").getFullList(opts);
|
|
return Response.json({
|
|
ok: true,
|
|
items: records.map((r: any) => ({
|
|
id: r.id,
|
|
file_name: r.file_name,
|
|
file_size: r.file_size,
|
|
invoice_no: r.invoice_no,
|
|
status: r.status,
|
|
error: r.error,
|
|
parsed: r.parsed,
|
|
invoice: r.invoice,
|
|
created: r.created,
|
|
})),
|
|
});
|
|
} catch (e: any) {
|
|
return Response.json({ ok: false, error: e?.message || "Could not read the inbox" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// POST /api/invoices/intake
|
|
// Multipart: `file` (a PDF). The PDF is parsed deterministically, checked arithmetically,
|
|
// and only then written. A PDF that does not reconcile is stored in the inbox as a
|
|
// problem invoice with the reason attached — it is never partially imported.
|
|
export async function POST(req: Request) {
|
|
let form: FormData;
|
|
try {
|
|
form = await req.formData();
|
|
} catch {
|
|
return Response.json({ ok: false, error: "Expected multipart/form-data" }, { status: 400 });
|
|
}
|
|
|
|
const file = form.get("file");
|
|
if (!(file instanceof File) || file.size === 0) {
|
|
return Response.json({ ok: false, error: "No file received" }, { status: 400 });
|
|
}
|
|
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
|
return Response.json({ ok: false, error: "Only PDF invoices are accepted" }, { status: 400 });
|
|
}
|
|
if (file.size > MAX_BYTES) {
|
|
return Response.json(
|
|
{ ok: false, error: `File is ${(file.size / 1048576).toFixed(1)} MB — limit is 20 MB` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const pb = await serverAuth();
|
|
|
|
// Always record the attempt, so a bad PDF is never lost.
|
|
const recordFailure = async (reason: string, parsed?: unknown) => {
|
|
const form2 = new FormData();
|
|
form2.append("file_name", file.name);
|
|
form2.append("file_size", String(file.size));
|
|
form2.append("status", "failed");
|
|
form2.append("error", reason);
|
|
if (parsed) form2.append("parsed", JSON.stringify(parsed));
|
|
form2.append(
|
|
"source_pdf",
|
|
new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
|
|
);
|
|
const rec = await pb.collection("invoice_intake").create(form2);
|
|
revalidatePath("/income/invoices");
|
|
return Response.json({ ok: false, status: "failed", intakeId: rec.id, error: reason }, { status: 422 });
|
|
};
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = await parseInvoicePdf(bytes);
|
|
} catch (e: any) {
|
|
if (e instanceof ParseError) return recordFailure(e.reason);
|
|
return recordFailure(`Could not read the PDF: ${e?.message ?? "unknown error"}`);
|
|
}
|
|
|
|
// Match each line to a plant item by the code the invoice prints. No match means the
|
|
// line is left unassigned for manual booking — never guessed.
|
|
const assets = await pb.collection("assets").getFullList();
|
|
const byCode = new Map<string, string>();
|
|
for (const a of assets) if (a.code) byCode.set(a.code, a.id);
|
|
|
|
// A duplicate is NOT a problem invoice. Nothing failed, and re-offering the same file
|
|
// can never succeed without first deleting the original — so it must not land in the
|
|
// retry inbox, where it would sit looking like something to fix. It is reported with
|
|
// its own status so the UI can say "you already have this" instead of "this failed".
|
|
const allInvoices = (await pb.collection("invoices").getFullList()) as any[];
|
|
const existing = allInvoices.find((i: any) => i.invoice_no === parsed.invoice_no);
|
|
if (existing) {
|
|
return Response.json(
|
|
{
|
|
ok: false,
|
|
status: "duplicate",
|
|
invoice_no: parsed.invoice_no,
|
|
error: `Invoice ${parsed.invoice_no} is already in the register — nothing was added.`,
|
|
existing: {
|
|
invoice_no: existing.invoice_no,
|
|
site: existing.site,
|
|
invoice_date: existing.invoice_date,
|
|
total_incl: existing.total_incl,
|
|
lines: (await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${existing.id}"` })).length,
|
|
invoiceId: existing.id,
|
|
},
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const periodEnd = new Date(parsed.period_to);
|
|
const bookedMonth = periodEnd.getUTCMonth() + 1;
|
|
const bookedYear = periodEnd.getUTCFullYear();
|
|
|
|
let invoiceRec;
|
|
try {
|
|
invoiceRec = await pb.collection("invoices").create({
|
|
invoice_no: parsed.invoice_no,
|
|
invoice_date: parsed.invoice_date,
|
|
order_no: parsed.order_no,
|
|
debtor: parsed.debtor,
|
|
debtor_no: parsed.debtor_no,
|
|
period_from: parsed.period_from,
|
|
period_to: parsed.period_to,
|
|
description: parsed.description,
|
|
site: parsed.site,
|
|
section: parsed.section,
|
|
category: parsed.category,
|
|
currency: parsed.currency,
|
|
vat_reg_no: parsed.vat_reg_no,
|
|
source_file: file.name,
|
|
subtotal: parsed.subtotal,
|
|
escalation: parsed.escalation,
|
|
vat: parsed.vat,
|
|
vat_pct: parsed.vat_pct,
|
|
total_excl: parsed.total_excl,
|
|
total_incl: parsed.total_incl,
|
|
booked_month: bookedMonth,
|
|
booked_year: bookedYear,
|
|
is_booked: false,
|
|
});
|
|
} catch (e: any) {
|
|
return recordFailure(
|
|
`Parsed correctly but could not save the invoice: ${e?.response?.data?.message || e?.message}`,
|
|
parsed,
|
|
);
|
|
}
|
|
|
|
// The invoice is written, so this site is now real. Sites are derived, never typed.
|
|
await syncSitesFromInvoices(pb, [...allInvoices, invoiceRec as any]);
|
|
|
|
const unassigned: string[] = [];
|
|
for (let i = 0; i < parsed.lines.length; i++) {
|
|
const l = parsed.lines[i];
|
|
const registerCode = registerCodeFrom(l.machine_code);
|
|
const assetId = byCode.get(registerCode) || "";
|
|
if (!assetId) unassigned.push(l.machine_code);
|
|
try {
|
|
await pb.collection("invoice_lines").create({
|
|
invoice: invoiceRec.id,
|
|
line_no: i + 1,
|
|
machine_code: l.machine_code,
|
|
asset_code: registerCode,
|
|
description: l.description,
|
|
qty: l.qty,
|
|
unit: l.unit,
|
|
rate: l.rate,
|
|
rate_type: l.rate_type,
|
|
amount_excl: l.amount_excl,
|
|
vat: l.vat,
|
|
total_incl: l.total_incl,
|
|
booked_asset: assetId,
|
|
booked_month: bookedMonth,
|
|
booked_year: bookedYear,
|
|
});
|
|
} catch (e: any) {
|
|
// A half-written invoice is worse than none — roll it back and report.
|
|
const written = await pb
|
|
.collection("invoice_lines")
|
|
.getFullList({ filter: `invoice = "${invoiceRec.id}"` });
|
|
for (const w of written) await pb.collection("invoice_lines").delete(w.id);
|
|
await pb.collection("invoices").delete(invoiceRec.id);
|
|
return recordFailure(
|
|
`Parsed correctly but a line could not be saved, so the import was rolled back: ${e?.response?.data?.message || e?.message}`,
|
|
parsed,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Book whatever could be matched. Anything unassigned stays visibly unbooked.
|
|
const lines = await pb.collection("invoice_lines").getFullList({ filter: `invoice = "${invoiceRec.id}"` });
|
|
for (const line of lines) {
|
|
if (!line.booked_asset) continue;
|
|
await pb.collection("monthly_income").create({
|
|
asset: line.booked_asset,
|
|
year: bookedYear,
|
|
month: bookedMonth,
|
|
income: line.total_incl,
|
|
fixed_expenses: 0,
|
|
is_forecast: false,
|
|
invoice_line: line.id,
|
|
});
|
|
}
|
|
const allBooked = lines.every((l: any) => l.booked_asset);
|
|
await pb.collection("invoices").update(invoiceRec.id, { is_booked: allBooked });
|
|
|
|
const form2 = new FormData();
|
|
form2.append("file_name", file.name);
|
|
form2.append("file_size", String(file.size));
|
|
form2.append("invoice_no", parsed.invoice_no);
|
|
form2.append("status", "imported");
|
|
form2.append("invoice", invoiceRec.id);
|
|
form2.append("parsed", JSON.stringify(parsed));
|
|
form2.append(
|
|
"source_pdf",
|
|
new File([bytes], file.name.replace(/[^\w.\- ]+/g, "_"), { type: "application/pdf" }),
|
|
);
|
|
await pb.collection("invoice_intake").create(form2);
|
|
|
|
revalidatePath("/income/invoices");
|
|
revalidatePath("/income");
|
|
|
|
return Response.json({
|
|
ok: true,
|
|
status: "imported",
|
|
invoice_no: parsed.invoice_no,
|
|
invoiceId: invoiceRec.id,
|
|
site: parsed.site,
|
|
total_incl: parsed.total_incl,
|
|
lines: lines.length,
|
|
booked: lines.filter((l: any) => l.booked_asset).length,
|
|
unassigned,
|
|
fullyBooked: allBooked,
|
|
});
|
|
} |