fix: prevent page overlap in delivery reports

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
This commit is contained in:
SJCoetz 2026-07-21 13:49:36 +02:00
parent 6f5108ca63
commit 4a18b8789e
3 changed files with 166 additions and 3 deletions

View file

@ -4,6 +4,7 @@ import {
drawText, drawText,
drawTable, drawTable,
moveDown, moveDown,
ensureSpace,
COLORS, COLORS,
type PDFContext, type PDFContext,
} from "./shared"; } from "./shared";
@ -85,7 +86,10 @@ async function customerDelivery(ctx: ReportContext): Promise<Uint8Array> {
const grouped = groupBy(ctx.notes, (n) => n.customer); const grouped = groupBy(ctx.notes, (n) => n.customer);
for (const [customerId, notes] of grouped) { for (const [customerId, notes] of grouped) {
if (pdf.y < 100) drawHeader(pdf, "Customer Delivery Report", "(continued)"); // 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); const customer = customers.get(customerId);
drawText(pdf, customer?.name || customerId, { size: 12, bold: true }); drawText(pdf, customer?.name || customerId, { size: 12, bold: true });
moveDown(pdf, 14); moveDown(pdf, 14);
@ -136,7 +140,7 @@ async function vehicleDelivery(ctx: ReportContext): Promise<Uint8Array> {
drawHeader(pdf, "Vehicle Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); drawHeader(pdf, "Vehicle Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
const grouped = groupBy(ctx.notes, (n) => n.vehicle_registration || "(none)"); const grouped = groupBy(ctx.notes, (n) => n.vehicle_registration || "(none)");
for (const [vehicle, notes] of grouped) { for (const [vehicle, notes] of grouped) {
if (pdf.y < 100) drawHeader(pdf, "Vehicle Delivery Report", "(continued)"); ensureSpace(pdf, 120, "Vehicle Delivery Report (continued)");
drawText(pdf, vehicle, { size: 12, bold: true }); drawText(pdf, vehicle, { size: 12, bold: true });
moveDown(pdf, 14); moveDown(pdf, 14);
const cols = [ const cols = [
@ -207,7 +211,7 @@ async function tankDelivery(ctx: ReportContext): Promise<Uint8Array> {
drawHeader(pdf, "Tank Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`); drawHeader(pdf, "Tank Delivery Report", `${formatDate(ctx.startDate)} to ${formatDate(ctx.endDate)}`);
const grouped = groupBy(ctx.notes, (n) => n.plant_dispensing || "(unspecified)"); const grouped = groupBy(ctx.notes, (n) => n.plant_dispensing || "(unspecified)");
for (const [tank, notes] of grouped) { for (const [tank, notes] of grouped) {
if (pdf.y < 100) drawHeader(pdf, "Tank Delivery Report", "(continued)"); ensureSpace(pdf, 120, "Tank Delivery Report (continued)");
drawText(pdf, tank, { size: 12, bold: true }); drawText(pdf, tank, { size: 12, bold: true });
moveDown(pdf, 14); moveDown(pdf, 14);
const customers = new Map(ctx.customers.map((c) => [c.id, c])); const customers = new Map(ctx.customers.map((c) => [c.id, c]));

View file

@ -0,0 +1,110 @@
/**
* Reproduce the Customer Delivery Report page-overlap bug.
* Builds a synthetic dataset (many customers, 4–14 rows each) that
* overflows onto page 15, then renders each page to PNG.
*/
import { generateReport } from "../lib/pdf/reports.ts";
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import puppeteer from "puppeteer-core";
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const OUT_PDF = "/tmp/rjbulk-pdfs/customer-delivery-diag.pdf";
const OUT_PNG_DIR = "/tmp/rjbulk-pdf-images";
mkdirSync("/tmp/rjbulk-pdfs", { recursive: true });
mkdirSync(OUT_PNG_DIR, { recursive: true });
// Build a fake dataset: 60 customers, varying row counts (mostly 2–8 rows),
// simulating a busy month with many small customers.
const customers = [];
const notes = [];
const customerNames = [
"PJ V Vista", "Blou Investments 273", "Joggie Muller busdienste", "Cash Strydom Trailers",
"Blanview Towing", "Eastvaal Motors", "Pretoria Sand & Stone", "Highveld Logistics",
"Riverside Civils", "Mountainview Plant Hire", "Northcliff Construction", "Sunward Park Quarries",
"Westonaria Mining", "Boksburg Civils", "Kempton Haulage", "Midrand Plant",
"Centurion Earthmovers", "Vanderbijlpulp Steel", "Sasolburg Transport", "Vereeniging Sands",
"Nigel Aggregates", "Springs Heavy Haul", "Benoni Grading", "Brakpan Civils",
"Alrode Civils", "Germiston Plant", "Edenvale Haulage", "Bedfordview Earth",
"Sandton Civils", "Bryanston Plant", "Fourways Construction", "Lanseria Civils",
"Honeydew Plant", "Krugersdorp Mining", "Randfontein Sands", "Carletonville Civils",
"Fochville Earth", "Potchefstroom Agri", "Stilfontein Mining", "Klerksdorp Steel",
"Welkom Civils", "Virginia Mining", "Sasolburg Chemicals", "Secunda Plant",
"Trichardt Civils", "Bethal Mining", "Ermelo Agri", "Morgenzon Plant",
"Standerton Civils", "Volksrust Haulage", "Lichtenburg Agri", "Coligny Plant",
"Delareyville Sands", "Vryburg Civils", "Kuruman Mining", "Hotazel Earth",
"Kathu Steel", "Postmasburg Civils", "Danielskuil Plant", "Douglas Sands",
];
customerNames.forEach((name, i) => {
const id = `cust_${i + 1}`;
customers.push({ id, name });
});
let noteCounter = 35000;
const vehicles = ["FX37NBQP", "FV76YPGP", "ETX550QP", "KX53NWSW", "DH27NXGP", "KY53GTQP", "JP75NBQP"];
const baseDate = new Date("2026-07-01");
customerNames.forEach((name, i) => {
// mostly 4–10 rows per customer (matches the user's pattern) — yields ~15 pages
const rowCount = 4 + (i % 7);
for (let r = 0; r < rowCount; r++) {
const d = new Date(baseDate);
d.setDate(d.getDate() + r + (i % 3));
const qty = 50 + Math.round(Math.random() * 950);
notes.push({
id: `note_${noteCounter}`,
customer: `cust_${i + 1}`,
number: String(noteCounter++),
date: d.toISOString().slice(0, 10),
qty_litres_supplied: qty,
price_per_litre: 22.5,
total_value: qty * 22.5,
vehicle_registration: vehicles[r % vehicles.length],
});
}
});
console.log(`Built ${customers.length} customers, ${notes.length} notes`);
const pdf = await generateReport("customer-delivery", {
startDate: "2026-07-01",
endDate: "2026-07-31",
includePricing: true,
notes,
purchases: [],
customers,
suppliers: [],
});
writeFileSync(OUT_PDF, pdf);
console.log(`Wrote ${OUT_PDF} (${pdf.length} bytes)`);
// Render every page to PNG using pdf-lib + an image lib is overkill.
// Instead use Playwright/Chromium to display the PDF and screenshot each page.
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
args: ["--no-sandbox", "--disable-gpu"],
});
const page = await browser.newPage();
await page.setViewport({ width: 900, height: 1200 });
await page.goto(`file://${OUT_PDF}`, { waitUntil: "networkidle2", timeout: 30000 });
await new Promise((r) => setTimeout(r, 800));
const totalPages = await page.evaluate(() => {
const m = document.body.innerText.match(/(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[2], 10) : 1;
});
console.log(`Total pages: ${totalPages}`);
for (let i = 1; i <= Math.min(totalPages, 25); i++) {
await page.goto(`file://${OUT_PDF}#page=${i}`, { waitUntil: "networkidle2" });
await new Promise((r) => setTimeout(r, 400));
await page.screenshot({ path: join(OUT_PNG_DIR, `diag-page-${String(i).padStart(2, "0")}.png`) });
console.log(` ✓ page ${i}`);
}
await browser.close();
console.log("Done");

49
tmp/render-all-pages.mjs Normal file
View file

@ -0,0 +1,49 @@
/**
* Render every page of the diagnostic PDF using Chromium's built-in PDF viewer
* and the pdf.js `applicationOptions` to size the viewport properly.
*/
import puppeteer from "puppeteer-core";
import { readFileSync, mkdirSync, readdirSync } from "fs";
import { join } from "path";
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const PDF = "/tmp/rjbulk-pdfs/customer-delivery-diag.pdf";
const OUT_DIR = "/tmp/rjbulk-pdf-images";
mkdirSync(OUT_DIR, { recursive: true });
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: "new",
args: ["--no-sandbox", "--disable-gpu"],
});
// We embed the PDF in an <embed> on a blank HTML page so we can use the
// `#page=N` URL fragment which Chromium's PDF viewer honours.
const html = `<!doctype html><html><body style="margin:0;padding:0">
<embed src="file://${PDF}" type="application/pdf" width="100%" height="1200" id="pdf"/>
</body></html>`;
const hostPath = "/tmp/rjbulk-pdfs/_host.html";
const { writeFileSync } = await import("fs");
writeFileSync(hostPath, html);
const page = await browser.newPage();
await page.setViewport({ width: 900, height: 1300 });
await page.goto(`file://${hostPath}`, { waitUntil: "networkidle2", timeout: 30000 });
await new Promise((r) => setTimeout(r, 1500));
// 1/6 means 6 pages — read it from the toolbar
const totalPages = await page.evaluate(() => {
const m = document.body.innerText.match(/\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : 1;
});
console.log(`Total pages: ${totalPages}`);
for (let i = 1; i <= totalPages; i++) {
await page.goto(`file://${PDF}#page=${i}&zoom=100`, { waitUntil: "networkidle2" });
await new Promise((r) => setTimeout(r, 600));
await page.screenshot({ path: join(OUT_DIR, `diag-page-${String(i).padStart(2, "0")}.png`) });
console.log(` ✓ page ${i}`);
}
await browser.close();