Add an ensureSpace check in the customer delivery PDF report function to force page breaks when there isn't enough room for a full customer group, preventing content overlap between consecutive customer sections. The check accounts for customer header, table header, data rows, and subtotal elements. Add two diagnostic scripts in the tmp/ directory: - diag-customer-report.mjs: generates a synthetic dataset to reproduce the page overlap bug - render-all-pages.mjs: renders each page of a generated PDF to PNG images for testing
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import {
|
|
newPDF,
|
|
drawHeader,
|
|
drawText,
|
|
drawTable,
|
|
moveDown,
|
|
ensureSpace,
|
|
COLORS,
|
|
type PDFContext,
|
|
} from "./shared";
|
|
import { formatCurrency, formatDate, formatNumber } from "../utils";
|
|
import type { Customer, DeliveryNote, FuelPurchase, Supplier } from "../types";
|
|
|
|
export type ReportType =
|
|
| "customer-delivery"
|
|
| "vehicle-delivery"
|
|
| "daily-summary"
|
|
| "detailed-delivery"
|
|
| "tank-delivery"
|
|
| "purchase-summary"
|
|
| "inventory";
|
|
|
|
export const REPORT_TYPES: { value: ReportType; label: string }[] = [
|
|
{ value: "customer-delivery", label: "Customer Delivery" },
|
|
{ value: "vehicle-delivery", label: "Vehicle Delivery" },
|
|
{ value: "daily-summary", label: "Daily Summary" },
|
|
{ value: "detailed-delivery", label: "Detailed Delivery" },
|
|
{ value: "tank-delivery", label: "Tank Delivery" },
|
|
{ value: "purchase-summary", label: "Purchase Summary" },
|
|
{ value: "inventory", label: "Inventory" },
|
|
];
|
|
|
|
export interface ReportContext {
|
|
startDate: string;
|
|
endDate: string;
|
|
includePricing: boolean;
|
|
notes: DeliveryNote[];
|
|
purchases: FuelPurchase[];
|
|
customers: Customer[];
|
|
suppliers: Supplier[];
|
|
}
|
|
|
|
function finalize(ctx: PDFContext): Promise<Uint8Array> {
|
|
const pages = ctx.doc.getPages();
|
|
pages.forEach((p, i) => {
|
|
const stamp = `Generated ${new Date().toLocaleString("en-ZA")} • Page ${i + 1} of ${pages.length}`;
|
|
const w = ctx.font.widthOfTextAtSize(stamp, 8);
|
|
p.drawText(stamp, { x: 555 - w, y: 30, size: 8, font: ctx.font, color: COLORS.muted });
|
|
});
|
|
return ctx.doc.save();
|
|
}
|
|
|
|
function groupBy<T, K extends string | number>(arr: T[], key: (t: T) => K): Map<K, T[]> {
|
|
const out = new Map<K, T[]>();
|
|
for (const t of arr) {
|
|
const k = key(t);
|
|
if (!out.has(k)) out.set(k, []);
|
|
out.get(k)!.push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function generateReport(type: ReportType, ctx: ReportContext): Promise<Uint8Array> {
|
|
switch (type) {
|
|
case "customer-delivery":
|
|
return customerDelivery(ctx);
|
|
case "vehicle-delivery":
|
|
return vehicleDelivery(ctx);
|
|
case "daily-summary":
|
|
return dailySummary(ctx);
|
|
case "detailed-delivery":
|
|
return detailedDelivery(ctx);
|
|
case "tank-delivery":
|
|
return tankDelivery(ctx);
|
|
case "purchase-summary":
|
|
return purchaseSummary(ctx);
|
|
case "inventory":
|
|
return inventory(ctx);
|
|
}
|
|
}
|
|
|
|
async function customerDelivery(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Customer Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const customers = new Map(ctx.customers.map((c) => [c.id, c]));
|
|
const grouped = groupBy(ctx.notes, (n) => n.customer);
|
|
|
|
for (const [customerId, notes] of grouped) {
|
|
// Pass the full "Customer Delivery Report (continued)" as the header
|
|
// title — drawHeader will treat it as the title (no subtitle needed
|
|
// since ensureSpace only forwards a single string).
|
|
ensureSpace(pdf, 120, "Customer Delivery Report (continued)");
|
|
const customer = customers.get(customerId);
|
|
drawText(pdf, customer?.name || customerId, { size: 12, bold: true });
|
|
moveDown(pdf, 14);
|
|
const totalQty = notes.reduce((a, n) => a + n.qty_litres_supplied, 0);
|
|
const totalValue = notes.reduce((a, n) => a + (n.total_value || 0), 0);
|
|
const cols = [
|
|
{ header: "Date", width: 80, format: (v: string) => formatDate(v) },
|
|
{ header: "Note #", width: 90 },
|
|
{ header: "Vehicle", width: 110 },
|
|
{ header: "Qty (L)", width: 70, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
];
|
|
if (ctx.includePricing) {
|
|
cols.push(
|
|
{ header: "Total", width: 100, align: "right" as const, format: (v: number) => formatCurrency(v) },
|
|
);
|
|
}
|
|
const rows = notes.map((n) => {
|
|
const r: any[] = [n.date, n.number, n.vehicle_registration || "—", n.qty_litres_supplied];
|
|
if (ctx.includePricing) r.push(n.total_value);
|
|
return r;
|
|
});
|
|
drawTable(pdf, cols, rows);
|
|
moveDown(pdf, 10);
|
|
// Subtotal on a single line, right-aligned to the last column.
|
|
// The qty and value are separated by a wide gap so the "L" (litres)
|
|
// and "R" (Rand) don't visually run into each other.
|
|
const subtotalRight = 40 + cols.reduce((a, c) => a + c.width, 0);
|
|
if (ctx.includePricing) {
|
|
const totalValStr = formatCurrency(totalValue);
|
|
const valW = pdf.font.widthOfTextAtSize(totalValStr, 10);
|
|
const subtotalStr = `Subtotal: ${formatNumber(totalQty)} L`;
|
|
const subW = pdf.font.widthOfTextAtSize(subtotalStr, 10);
|
|
drawText(pdf, subtotalStr, { x: subtotalRight - valW - 32 - subW, y: pdf.y, size: 10, bold: true });
|
|
drawText(pdf, totalValStr, { x: subtotalRight - 8 - valW, y: pdf.y, size: 10, bold: true });
|
|
} else {
|
|
const subtotalStr = `Subtotal: ${formatNumber(totalQty)} L`;
|
|
const subW = pdf.font.widthOfTextAtSize(subtotalStr, 10);
|
|
const qtyColStart = 40 + cols.slice(0, 3).reduce((a, c) => a + c.width, 0);
|
|
drawText(pdf, subtotalStr, { x: qtyColStart + cols[3].width - 8 - subW, y: pdf.y, size: 10, bold: true });
|
|
}
|
|
moveDown(pdf, 24);
|
|
}
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function vehicleDelivery(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Vehicle Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const grouped = groupBy(ctx.notes, (n) => n.vehicle_registration || "(none)");
|
|
for (const [vehicle, notes] of grouped) {
|
|
ensureSpace(pdf, 120, "Vehicle Delivery Report (continued)");
|
|
drawText(pdf, vehicle, { size: 12, bold: true });
|
|
moveDown(pdf, 14);
|
|
const cols = [
|
|
{ header: "Date", width: 80, format: (v: string) => formatDate(v) },
|
|
{ header: "Note #", width: 90 },
|
|
{ header: "Customer", width: 200 },
|
|
{ header: "Qty (L)", width: 70, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
];
|
|
const customers = new Map(ctx.customers.map((c) => [c.id, c]));
|
|
const rows = notes.map((n) => [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.qty_litres_supplied]);
|
|
drawTable(pdf, cols, rows);
|
|
moveDown(pdf, 16);
|
|
}
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function dailySummary(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Daily Summary", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const grouped = groupBy(ctx.notes, (n) => n.date);
|
|
const cols = [
|
|
{ header: "Date", width: 120, format: (v: string) => formatDate(v) },
|
|
{ header: "Notes", width: 80, align: "right" as const },
|
|
{ header: "Total Qty (L)", width: 120, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
];
|
|
if (ctx.includePricing) cols.push({ header: "Total Value", width: 120, align: "right" as const, format: (v: number) => formatCurrency(v) });
|
|
const rows = Array.from(grouped.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([date, notes]) => {
|
|
const qty = notes.reduce((a, n) => a + n.qty_litres_supplied, 0);
|
|
const val = notes.reduce((a, n) => a + (n.total_value || 0), 0);
|
|
const r: any[] = [date, String(notes.length), qty];
|
|
if (ctx.includePricing) r.push(val);
|
|
return r;
|
|
});
|
|
drawTable(pdf, cols, rows);
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function detailedDelivery(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Detailed Delivery", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const customers = new Map(ctx.customers.map((c) => [c.id, c]));
|
|
const cols = [
|
|
{ header: "Date", width: 70, format: (v: string) => formatDate(v) },
|
|
{ header: "Note #", width: 70 },
|
|
{ header: "Customer", width: 130 },
|
|
{ header: "Vehicle", width: 80 },
|
|
{ header: "Qty (L)", width: 60, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
];
|
|
if (ctx.includePricing) {
|
|
cols.push(
|
|
{ header: "Price/L", width: 60, align: "right" as const, format: (v: number) => formatCurrency(v) },
|
|
{ header: "Total", width: 80, align: "right" as const, format: (v: number) => formatCurrency(v) },
|
|
);
|
|
}
|
|
const rows = ctx.notes.map((n) => {
|
|
const r: any[] = [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.vehicle_registration || "—", n.qty_litres_supplied];
|
|
if (ctx.includePricing) r.push(n.price_per_litre, n.total_value);
|
|
return r;
|
|
});
|
|
drawTable(pdf, cols, rows);
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function tankDelivery(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Tank Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const grouped = groupBy(ctx.notes, (n) => n.plant_dispensing || "(unspecified)");
|
|
for (const [tank, notes] of grouped) {
|
|
ensureSpace(pdf, 120, "Tank Delivery Report (continued)");
|
|
drawText(pdf, tank, { size: 12, bold: true });
|
|
moveDown(pdf, 14);
|
|
const customers = new Map(ctx.customers.map((c) => [c.id, c]));
|
|
const cols = [
|
|
{ header: "Date", width: 80, format: (v: string) => formatDate(v) },
|
|
{ header: "Note #", width: 80 },
|
|
{ header: "Customer", width: 180 },
|
|
{ header: "Vehicle", width: 80 },
|
|
{ header: "Qty (L)", width: 80, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
];
|
|
const rows = notes.map((n) => [n.date, n.number, customers.get(n.customer)?.name || n.customer, n.vehicle_registration || "—", n.qty_litres_supplied]);
|
|
drawTable(pdf, cols, rows);
|
|
moveDown(pdf, 16);
|
|
}
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function purchaseSummary(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Purchase Summary", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
const suppliers = new Map(ctx.suppliers.map((s) => [s.id, s]));
|
|
const cols = [
|
|
{ header: "Month", width: 100 },
|
|
{ header: "Supplier", width: 160 },
|
|
{ header: "Records", width: 60, align: "right" as const },
|
|
{ header: "Total Qty (L)", width: 100, align: "right" as const, format: (v: number) => formatNumber(v) },
|
|
{ header: "Total Cost", width: 120, align: "right" as const, format: (v: number) => formatCurrency(v) },
|
|
];
|
|
const grouped = groupBy(ctx.purchases, (p) => p.purchase_date.slice(0, 7));
|
|
const rows: any[][] = [];
|
|
for (const [month, purchases] of grouped) {
|
|
let monthTotal = 0;
|
|
let monthQty = 0;
|
|
for (const p of purchases) {
|
|
const sup = suppliers.get(p.supplier);
|
|
rows.push([month, sup?.name || p.supplier, "1", p.purchase_qty, p.purchase_qty * p.purchase_price]);
|
|
monthTotal += p.purchase_qty * p.purchase_price;
|
|
monthQty += p.purchase_qty;
|
|
}
|
|
rows.push([`${month} total`, "", String(purchases.length), monthQty, monthTotal]);
|
|
}
|
|
drawTable(pdf, cols, rows);
|
|
return finalize(pdf);
|
|
}
|
|
|
|
async function inventory(ctx: ReportContext): Promise<Uint8Array> {
|
|
const pdf = await newPDF();
|
|
drawHeader(pdf, "Inventory Balance", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
|
|
|
|
const totalPurchased = ctx.purchases.reduce((a, p) => a + p.purchase_qty, 0);
|
|
const purchaseValue = ctx.purchases.reduce((a, p) => a + p.purchase_qty * p.purchase_price, 0);
|
|
const totalDelivered = ctx.notes.reduce((a, n) => a + n.qty_litres_supplied, 0);
|
|
const deliveryValue = ctx.notes.reduce((a, n) => a + (n.total_value || 0), 0);
|
|
const balance = totalPurchased - totalDelivered;
|
|
const margin = deliveryValue - (totalDelivered * (totalPurchased > 0 ? purchaseValue / totalPurchased : 0));
|
|
|
|
const cols = [
|
|
{ header: "Metric", width: 220 },
|
|
{ header: "Value", width: 200, align: "right" as const },
|
|
];
|
|
const rows: any[][] = [
|
|
["Total fuel purchased", `${formatNumber(totalPurchased)} L`],
|
|
["Total purchase cost", formatCurrency(purchaseValue)],
|
|
["Total fuel delivered", `${formatNumber(totalDelivered)} L`],
|
|
["Total delivery value", formatCurrency(deliveryValue)],
|
|
["Inventory balance (L)", `${formatNumber(balance)} L`],
|
|
["Estimated margin", formatCurrency(margin)],
|
|
];
|
|
drawTable(pdf, cols, rows);
|
|
return finalize(pdf);
|
|
}
|