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:
parent
6f5108ca63
commit
4a18b8789e
3 changed files with 166 additions and 3 deletions
|
|
@ -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]));
|
||||||
|
|
|
||||||
110
tmp/diag-customer-report.mjs
Normal file
110
tmp/diag-customer-report.mjs
Normal 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
49
tmp/render-all-pages.mjs
Normal 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();
|
||||||
Loading…
Reference in a new issue