Merge branch 'feat/pos-delivery-notes-and-scan'
Account sales at the POS now raise a real delivery note instead of a
device-rendered thermal slip, every delivery note prints a scannable QR of
its number, and a standalone /scan PWA turns that QR into reprint /
capture-signed-copy / ready-to-invoice.
Carries two fixes found on the way:
- the PDF pipeline rendered through a JS string, which left every
document uncompressed and silently dropped any raster image
- printing a PDF iframe printed Chrome's empty pdf_embedder shell,
i.e. a dialog of blank pages
Migration 2026-10-03 (pos_sales.delivery_note) is idempotent and has
already been applied to live PocketBase.
This commit is contained in:
commit
8f993d5fcd
37 changed files with 17006 additions and 14048 deletions
27668
package-lock.json
generated
27668
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
|||
"pb:migrate:order-status-in-stock": "echo 'deprecated: orders.status was dropped 2026-06-27. No-op.' && exit 0",
|
||||
"pb:migrate:orders-rep": "npx tsx scripts/migrations/2026-06-26-add-orders-rep.ts",
|
||||
"pb:migrate:pos-slip-layouts": "npx tsx scripts/migrations/2026-09-04-create-pos-slip-layouts.ts",
|
||||
"pb:migrate:pos-sales-delivery-note": "npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts",
|
||||
"pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts",
|
||||
"pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts",
|
||||
"pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts",
|
||||
|
|
@ -88,6 +89,7 @@
|
|||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pocketbase": "^0.26.9",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
|
|
@ -107,12 +109,14 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
|
|
|
|||
27
public/manifest-scan.json
Normal file
27
public/manifest-scan.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "WFDASH Delivery Scanner",
|
||||
"short_name": "DN Scanner",
|
||||
"description": "Scan the QR on a delivery note to reprint it, capture the signed copy, or move it to ready to invoice",
|
||||
"start_url": "/scan",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FFFFFF",
|
||||
"theme_color": "#4C96D7",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"categories": ["business", "productivity", "utilities"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
|
|
@ -291,12 +291,32 @@ window.reprint = function(orderId){
|
|||
qty: it.qty,
|
||||
product_id: it.stock_item,
|
||||
})),
|
||||
// The API returns the customer as a relation id only (no name
|
||||
// expansion in the sales list). Pass null so receipt.html's
|
||||
// `if (s.customer)` guard skips the line — better than printing
|
||||
// "Customer: undefined". If we need the name, the user can add
|
||||
// a /api/v1/pos/sales/:id endpoint that expands it.
|
||||
customer: null,
|
||||
// An account sale has a real delivery note behind it (raised
|
||||
// server-side inside `checkoutSale`). Carry the link across so
|
||||
// receipt.html can show the DN number and reprint the A4 original
|
||||
// — without this the reprint screen said "Not created" for a DN
|
||||
// that exists, and offered no way to print it.
|
||||
deliveryNote: (s.expand && s.expand.delivery_note && s.expand.delivery_note.delivery_note_number)
|
||||
? {
|
||||
id: s.delivery_note,
|
||||
delivery_note_number: s.expand.delivery_note.delivery_note_number,
|
||||
}
|
||||
: null,
|
||||
// Expanded by GET /api/v1/pos/sales (customer + its company +
|
||||
// the linked DN). Needed so the reprinted DN card names the
|
||||
// account the client is about to sign for.
|
||||
customer: s.expand && s.expand.customer
|
||||
? {
|
||||
id: s.expand.customer.id,
|
||||
name: s.expand.customer.customer,
|
||||
// `customer.company` is a relation id; the NAME lives on the
|
||||
// nested expand. Without it the card showed "rpqogiu03d97lcv"
|
||||
// where a company belongs.
|
||||
company: (s.expand.customer.expand
|
||||
&& s.expand.customer.expand.company
|
||||
&& s.expand.customer.expand.company.company) || '',
|
||||
}
|
||||
: null,
|
||||
synced: true,
|
||||
serverRef: s.id,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -883,6 +883,14 @@ async function confirmSale(){
|
|||
const j = await r.json();
|
||||
sale.synced = true;
|
||||
sale.serverRef = j.sale && j.sale.id;
|
||||
// The server raises the delivery note for an `account` sale and
|
||||
// hands it back here. Carried onto the local sale so
|
||||
// receipt.html can print the A4 original instead of the old
|
||||
// 80mm thermal slip. Absent for cash / card / EFT, and absent
|
||||
// for an account sale whose DN creation failed — in which case
|
||||
// the operator is told to raise one by hand.
|
||||
if (j.deliveryNote) sale.deliveryNote = j.deliveryNote;
|
||||
if (j.deliveryNoteError) sale.deliveryNoteError = j.deliveryNoteError;
|
||||
} else {
|
||||
// The route returns JSON on every non-2xx path, so this should
|
||||
// always parse. Overselling is allowed on purpose (no stock
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ input,select{font:inherit;color:inherit}
|
|||
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
|
||||
.dn-toggle input{width:16px;height:16px}
|
||||
@media print{.dn-toggle{display:none}}
|
||||
/* System-DN state note. Green = printable, amber = waiting on the
|
||||
network, red = a human has to raise the DN by hand. */
|
||||
.dn-note{margin:0;font-size:13px;line-height:1.45;padding:11px 12px;border-radius:10px;border:1px solid transparent}
|
||||
.dn-note-linked{background:rgba(16,133,101,.08);border-color:rgba(16,133,101,.28);color:var(--fg)}
|
||||
.dn-note-pending{background:rgba(200,140,20,.10);border-color:rgba(200,140,20,.32);color:var(--fg)}
|
||||
.dn-note-failed{background:rgba(201,42,42,.10);border-color:rgba(201,42,42,.32);color:var(--fg)}
|
||||
.dn-sign{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding-top:18px;margin-top:6px;border-top:1px dashed var(--muted)}
|
||||
.dn-sign > div{padding-top:8px;border-top:1.5px solid var(--fg);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);text-align:center}
|
||||
|
||||
|
|
@ -324,7 +330,7 @@ input,select{font:inherit;color:inherit}
|
|||
</button>
|
||||
<button class="btn acct" data-od-id="print-delivery" id="printDnBtn" style="display:none" onclick="printDelivery()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 4h11l3 3v13H5zM9 9h6M9 13h6M9 17h4"/></svg>
|
||||
Print delivery note
|
||||
Print delivery note (A4)
|
||||
</button>
|
||||
<button class="btn" data-od-id="new-sale" onclick="location.href='pos.html'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>
|
||||
|
|
@ -363,10 +369,18 @@ if(!sale){
|
|||
if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
|
||||
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
|
||||
if(sale.payment==='account'){
|
||||
// Stashed so the "Include prices" checkbox can re-render in place.
|
||||
window.__lastSale = sale;
|
||||
document.getElementById('deliveryWrap').style.display='block';
|
||||
document.getElementById('printDnBtn').style.display='flex';
|
||||
// An account sale never gets a paper slip. The goods leave on the
|
||||
// delivery note and that is the document the client signs, so
|
||||
// offering "Print slip" here just invites someone to print the
|
||||
// wrong thing at the counter. The till slip stays for cash / card
|
||||
// / EFT, which are the tenders where payment is actually taken.
|
||||
const slipBtn = document.getElementById('printBtn');
|
||||
if (slipBtn) slipBtn.style.display = 'none';
|
||||
// The print button only makes sense once a DN actually exists.
|
||||
// A queued (offline) sale or a failed creation still shows the
|
||||
// card with its state, but has nothing to print yet.
|
||||
if(sale.deliveryNote) document.getElementById('printDnBtn').style.display='flex';
|
||||
renderDeliveryNote(sale);
|
||||
}
|
||||
}
|
||||
|
|
@ -465,44 +479,61 @@ function renderReceipt(s){
|
|||
document.getElementById('receiptCard').innerHTML = html;
|
||||
}
|
||||
|
||||
// The system delivery note. The account sale raised this server-side
|
||||
// inside `checkoutSale`; this card just shows the number the operator
|
||||
// quotes to the client and hands them the A4 print.
|
||||
//
|
||||
// The old 80mm thermal delivery-note slip is gone from this path by
|
||||
// design — the client gets the same document the office keeps, printed
|
||||
// on the office printer. The thermal slip still prints for cash / card
|
||||
// / EFT, which never reach this function.
|
||||
function renderDeliveryNote(s){
|
||||
const date = new Date(s.createdAt);
|
||||
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
||||
// Some customers must not see prices on the slip the driver carries;
|
||||
// others want them. Defaults from the letterhead's saved setting
|
||||
// (Settings → Edit slip layout); the checkbox is a per-print override.
|
||||
const toggle = document.getElementById('dnShowPrices');
|
||||
const letterheadIdDn = s.letterhead && s.letterhead.id;
|
||||
const showPrices = toggle ? !!toggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdDn);
|
||||
const lines = s.items.map(it=>{
|
||||
const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
|
||||
return `<div class="dn-line">
|
||||
<div class="nm">${esc(it.description||it.sku)}</div>
|
||||
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
|
||||
${price}
|
||||
</div>`;
|
||||
}).join('');
|
||||
const companyLine = s.customer && s.customer.company
|
||||
? `<div class="v">${esc(s.customer.company)}</div>` : '';
|
||||
const customerName = (s.customer && s.customer.name) || '—';
|
||||
const companyName = (s.customer && s.customer.company) || '';
|
||||
|
||||
// Three states, and the operator has to be able to tell them apart
|
||||
// at a glance:
|
||||
// linked — the DN exists, print it
|
||||
// pending — the sale is still in the offline queue; the DN is
|
||||
// raised automatically on the next reconnect
|
||||
// failed — the sale banked but the DN could not be created, so a
|
||||
// human has to raise it from the CRM
|
||||
let state, numberHtml, noteHtml;
|
||||
if (s.deliveryNote) {
|
||||
state = 'linked';
|
||||
numberHtml = `<div class="v" style="font-size:17px">${esc(s.deliveryNote.delivery_note_number)}</div>`;
|
||||
noteHtml = 'Print two copies for the client to sign — one to keep, one to bring back to the office.';
|
||||
} else if (s.synced) {
|
||||
state = 'failed';
|
||||
numberHtml = '<div class="v" style="font-size:17px">Not created</div>';
|
||||
noteHtml = esc(s.deliveryNoteError
|
||||
|| 'The sale was recorded but no delivery note was created. Raise one by hand from the CRM.');
|
||||
} else {
|
||||
state = 'pending';
|
||||
numberHtml = '<div class="v" style="font-size:17px">Pending</div>';
|
||||
noteHtml = 'This sale is saved on the till. The delivery note is raised automatically once the connection is back — pick it up from the CRM, not the thermal printer.';
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div class="dn-head">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
|
||||
<div class="nm">
|
||||
<div class="t">Delivery note</div>
|
||||
<div class="s">${esc((s.customer && s.customer.company) || (s.customer && s.customer.name) || '—')}</div>
|
||||
</div>
|
||||
<div class="dn-head">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
|
||||
<div class="nm">
|
||||
<div class="t">Delivery note</div>
|
||||
<div class="s">${esc(companyName || customerName)}</div>
|
||||
</div>
|
||||
<div class="dn-meta">
|
||||
<div><div class="k">Customer</div><div class="v">${esc((s.customer && s.customer.name)||'—')}</div>${companyLine}</div>
|
||||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||||
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
||||
${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
|
||||
</div>
|
||||
<label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
|
||||
<div class="dn-items">${lines}</div>
|
||||
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
|
||||
</div>
|
||||
<div class="dn-meta">
|
||||
<div><div class="k">Customer</div><div class="v">${esc(customerName)}</div>${companyName?`<div class="v">${esc(companyName)}</div>`:''}</div>
|
||||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||||
<div><div class="k">DN number</div>${numberHtml}</div>
|
||||
<div><div class="k">Items</div><div class="v">${s.items.length}</div></div>
|
||||
</div>
|
||||
<p class="dn-note dn-note-${state}">${noteHtml}</p>
|
||||
`;
|
||||
document.getElementById('deliveryCard').innerHTML = html;
|
||||
window.__dnState = state;
|
||||
}
|
||||
window.renderDeliveryNote = renderDeliveryNote;
|
||||
|
||||
|
|
@ -535,21 +566,15 @@ async function printThermalNetwork(settings){
|
|||
// hands to the customer at drop-off, not a "paid" receipt. Cash
|
||||
// / card / EFT get the standard sale slip.
|
||||
let xml, successLabel;
|
||||
if (sale.payment === 'account') {
|
||||
const letterheadId = sale.letterhead && sale.letterhead.id;
|
||||
const deliveryBlocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'delivery');
|
||||
const dnToggle = document.getElementById('dnShowPrices');
|
||||
const showPricing = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadId);
|
||||
const dnLogo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
|
||||
xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocks, showPricing, dnLogo);
|
||||
successLabel = 'Delivery note printed · ';
|
||||
} else {
|
||||
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
|
||||
const letterheadId = sale.letterhead && sale.letterhead.id;
|
||||
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
|
||||
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
|
||||
successLabel = 'Printed · ';
|
||||
}
|
||||
// Always the standard sale slip. Account sales no longer print a
|
||||
// thermal delivery note — the system A4 DN is printed by
|
||||
// `printDelivery()` instead, so this button is the till slip for
|
||||
// every tender type.
|
||||
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
|
||||
const letterheadId = sale.letterhead && sale.letterhead.id;
|
||||
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
|
||||
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
|
||||
successLabel = 'Printed · ';
|
||||
await window.EposPrint.printViaNetwork(settings.ip, xml);
|
||||
status.textContent = successLabel + settings.ip;
|
||||
}catch(e){
|
||||
|
|
@ -594,11 +619,10 @@ async function printThermalBluetooth(){
|
|||
// Account sales print a delivery note (received-by / driver lines,
|
||||
// no payment line). Everything else prints the standard sale slip.
|
||||
const letterheadIdBt = sale.letterhead && sale.letterhead.id;
|
||||
const dnToggle = document.getElementById('dnShowPrices');
|
||||
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBt);
|
||||
const text = sale.payment === 'account'
|
||||
? buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'delivery'))
|
||||
: buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
|
||||
// Standard sale slip for every tender — see the note on the
|
||||
// network path above. Account sales print the system A4 DN
|
||||
// through `printDelivery()` instead.
|
||||
const text = buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
|
||||
const CHUNK = 80;
|
||||
const enc = new TextEncoder();
|
||||
for(let i=0;i<text.length;i+=CHUNK){
|
||||
|
|
@ -606,7 +630,7 @@ async function printThermalBluetooth(){
|
|||
await btChar.writeValueWithoutResponse(enc.encode(slice));
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
status.textContent = (sale.payment === 'account' ? 'Delivery note printed · ' : 'Printed · ') + (btDevice.name||'printer');
|
||||
status.textContent = 'Printed · ' + (btDevice.name||'printer');
|
||||
}catch(e){
|
||||
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
|
||||
else status.textContent = 'Print failed: ' + e.message;
|
||||
|
|
@ -691,7 +715,7 @@ function saleEscPosRenderers(s){
|
|||
}
|
||||
|
||||
// `blocks` is the resolved block list for 'sale'. Returns a plain
|
||||
// string (matching buildEscPosDelivery) — the caller runs it through
|
||||
// string — the caller runs it through
|
||||
// TextEncoder before writing to the Bluetooth characteristic. (Note:
|
||||
// this used to manually pack a Uint8Array here, which the caller then
|
||||
// fed into TextEncoder.encode() — encoding a Uint8Array as if it were
|
||||
|
|
@ -704,132 +728,78 @@ function buildEscPos(s, blocks){
|
|||
return ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
|
||||
}
|
||||
|
||||
// Prints the SYSTEM delivery note — the A4 original the office keeps —
|
||||
// by opening it in a new tab and letting the operator send it to the
|
||||
// office network printer from there (set Copies to 2 for the client).
|
||||
//
|
||||
// This replaced the old 80mm thermal delivery-note print. The thermal
|
||||
// slip is still the path for cash / card / EFT via `printThermal`,
|
||||
// and its button is hidden for account sales entirely.
|
||||
async function printDelivery(){
|
||||
if(!sale) return;
|
||||
const dn = sale.deliveryNote;
|
||||
const btn = document.getElementById('printDnBtn');
|
||||
const status = document.getElementById('statusText');
|
||||
const errBox = document.getElementById('status');
|
||||
if(btn) btn.disabled = true;
|
||||
errBox.classList.remove('err');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Printing delivery note…';
|
||||
const printerSettings = window.EposPrint.getSettings();
|
||||
if(printerSettings.type === 'network'){
|
||||
if(!printerSettings.ip){
|
||||
status.textContent = 'Set the printer IP address in Printer settings first.';
|
||||
|
||||
if(!dn){
|
||||
if(status) status.textContent = 'No delivery note on this sale yet.';
|
||||
if(btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if(status) status.textContent = 'Fetching ' + dn.delivery_note_number + '…';
|
||||
let url = null;
|
||||
try{
|
||||
// Fetched through posFetch because the route sits behind
|
||||
// assertPosAuth — an <iframe src> or a plain window.open() to the
|
||||
// route cannot carry the bearer header, so the bytes come in as a
|
||||
// blob first and the blob URL is what gets opened.
|
||||
const r = await window.posFetch('/delivery-note/' + encodeURIComponent(dn.delivery_note_number) + '/pdf');
|
||||
if(!r.ok){
|
||||
let msg = 'HTTP ' + r.status;
|
||||
try{ const j = await r.json(); if(j && j.error) msg = j.error; }catch(_){}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await r.blob();
|
||||
url = URL.createObjectURL(blob);
|
||||
|
||||
// Open the blob in a NEW TAB rather than calling print() on a
|
||||
// hidden iframe.
|
||||
//
|
||||
// An earlier version of this built a 1x1 iframe and called
|
||||
// `contentWindow.print()`, which looks right and is not. Chrome
|
||||
// backs ANY pdf — src URL or blob URL — with its internal
|
||||
// `pdf_embedder` extension, so the frame's document is an empty
|
||||
// shell:
|
||||
//
|
||||
// <html><head><link rel="stylesheet"
|
||||
// href="chrome-extension://…/pdf_embedder.css"></head>
|
||||
// <body></body></html>
|
||||
//
|
||||
// `print()` then prints that empty body: a dialog of blank pages
|
||||
// with no error to explain why. Verified by reading the iframe's
|
||||
// document out of a live page. A new tab gets Chrome's real PDF
|
||||
// viewer, whose own Print button works.
|
||||
const win = window.open(url, '_blank');
|
||||
if(!win){
|
||||
errBox.classList.add('err');
|
||||
btn.disabled = false;
|
||||
status.textContent = 'Pop-up blocked — allow pop-ups for this site, or use the office computer to open the delivery note from WFDASH.';
|
||||
return;
|
||||
}
|
||||
const letterheadIdNet = sale.letterhead && sale.letterhead.id;
|
||||
const dnToggleNet = document.getElementById('dnShowPrices');
|
||||
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdNet);
|
||||
try{
|
||||
const deliveryBlocksNet = window.PosSlipLayout.getBlocksForPrint(letterheadIdNet, 'delivery');
|
||||
const dnLogoNet = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
|
||||
const xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocksNet, showPricesNet, dnLogoNet);
|
||||
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
|
||||
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
|
||||
}catch(e){
|
||||
errBox.classList.add('err');
|
||||
status.textContent = 'DN print failed: ' + e.message;
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!btChar){
|
||||
status.textContent = 'Pair a Bluetooth printer first to print.';
|
||||
errBox.classList.add('err');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const letterheadIdBtDn = sale.letterhead && sale.letterhead.id;
|
||||
const dnToggle = document.getElementById('dnShowPrices');
|
||||
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBtDn);
|
||||
const text = buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBtDn, 'delivery'));
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||||
try{
|
||||
for(let i=0;i<bytes.length;i+=80){
|
||||
await btChar.writeValueWithoutResponse(bytes.slice(i, i+80));
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
status.textContent = 'Delivery note printed.';
|
||||
status.textContent = dn.delivery_note_number + ' opened in a new tab — press Print there, and set Copies to 2 for the client.';
|
||||
}catch(e){
|
||||
errBox.classList.add('err');
|
||||
status.textContent = 'DN print failed: ' + e.message;
|
||||
if(status) status.textContent = 'DN print failed: ' + e.message;
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
// Keep the blob alive long enough for the new tab to load it.
|
||||
if(url) setTimeout(() => { URL.revokeObjectURL(url); }, 60000);
|
||||
if(btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the sale slip's header: same 2x-size title and the same
|
||||
// separate business_info block, so a delivery note reads as the same
|
||||
// company's paperwork. (Business info used to be baked into this
|
||||
// transport's header_title while the network XML version omitted it
|
||||
// entirely — the two transports have now been reconciled on the
|
||||
// sale-slip shape, and business_info is its own reorderable block.)
|
||||
function deliveryEscPosRenderers(s, showPrices){
|
||||
const E = (n) => String.fromCharCode(n);
|
||||
const ESC = E(0x1B);
|
||||
return {
|
||||
// Web Bluetooth GATT printing has no raster-image support in this
|
||||
// app — this block intentionally prints nothing on this transport.
|
||||
logo: () => '',
|
||||
header_title: (block) => {
|
||||
const title = block.text || 'WFSALES POS';
|
||||
return ESC+'a'+E(1) + ESC+'!'+E(0x30) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
|
||||
},
|
||||
business_info: () => {
|
||||
let out = ESC+'a'+E(1);
|
||||
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
|
||||
if(bizName) out += bizName + '\n';
|
||||
if(s.letterhead){
|
||||
if(s.letterhead.address) out += s.letterhead.address.split('\n')[0]+'\n';
|
||||
if(s.letterhead.phone) out += 'Tel: '+s.letterhead.phone+'\n';
|
||||
if(s.letterhead.vat_number) out += 'VAT: '+s.letterhead.vat_number+'\n';
|
||||
}
|
||||
return out + ESC+'a'+E(0);
|
||||
},
|
||||
customer: () => {
|
||||
let out = '';
|
||||
if(s.customer && s.customer.company) out += 'Company: ' + s.customer.company + '\n';
|
||||
out += 'Customer: ' + (s.customer && s.customer.name || '—') + '\n';
|
||||
return out;
|
||||
},
|
||||
date: () => {
|
||||
const d = new Date(s.createdAt);
|
||||
return 'Date: ' + d.toLocaleDateString('en-ZA')+'\n';
|
||||
},
|
||||
items: () => {
|
||||
let out = '--------------------------------\n';
|
||||
for(const it of s.items){
|
||||
out += (it.description||it.sku)+'\n';
|
||||
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
|
||||
out += showPrices ? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n` : `${qtyLine}\n`;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
totals: () => {
|
||||
let out = '--------------------------------\n';
|
||||
if(showPrices) out += 'TOTAL: ' + fmt(s.total) + '\n';
|
||||
return out;
|
||||
},
|
||||
signature: () => 'Received by: __________ Driver: __________\n',
|
||||
};
|
||||
}
|
||||
|
||||
// `blocks` is the resolved block list for 'delivery'. Returns a
|
||||
// plain string (see buildEscPos's comment on why — TextEncoder needs
|
||||
// a string, not a Uint8Array).
|
||||
function buildEscPosDelivery(s, showPrices, blocks){
|
||||
const E = (n) => String.fromCharCode(n);
|
||||
const ESC = E(0x1B);
|
||||
const renderers = deliveryEscPosRenderers(s, showPrices);
|
||||
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
|
||||
return ESC+'@' + body + '\n\n' + E(0x1D)+'V'+E(0x41)+E(0x10);
|
||||
}
|
||||
|
||||
function togglePrinterSettings(){
|
||||
const form = document.getElementById('printerSettingsForm');
|
||||
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||||
const CACHE = "pos-v26";
|
||||
// v28 — account sales can now reprint their delivery note from
|
||||
// history.html (the sale's linked DN is now carried into the reprint
|
||||
// screen), and the "Print slip" button is hidden for account sales —
|
||||
// the delivery note is the only document that tender produces.
|
||||
// Bumped so tills evict the stale app shell.
|
||||
const CACHE = "pos-v28";
|
||||
const APP_SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
|
|
|
|||
108
scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts
Normal file
108
scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Migration: add `delivery_note` to `pos_sales`.
|
||||
*
|
||||
* ## Why
|
||||
*
|
||||
* The POS used to render a delivery note entirely on the device
|
||||
* (`renderDeliveryNote` in `public/pos/receipt.html`) and print it on
|
||||
* the 80mm thermal printer. Nothing reached the server, so the DN the
|
||||
* driver carried had no record behind it.
|
||||
*
|
||||
* Now an `account` tender creates a real `delivery_notes` record
|
||||
* server-side, inside `checkoutSale`. This field is the idempotency
|
||||
* anchor for that: the till's offline queue re-posts `/checkout` with
|
||||
* the same `client_ref`, and without this field a replay would create a
|
||||
* SECOND delivery note for one physical sale.
|
||||
*
|
||||
* `checkoutSale` reads this on the replay path — if it's set, the
|
||||
* existing DN is returned untouched; if it's blank the creation is
|
||||
* attempted (covers the case where the sale banked but DN creation
|
||||
* failed).
|
||||
*
|
||||
* ## What it does
|
||||
*
|
||||
* Adds one field:
|
||||
*
|
||||
* - `delivery_note` (relation → delivery_notes, single, optional)
|
||||
* The system delivery note raised for this account sale.
|
||||
*
|
||||
* Only set on `payment_method = "account"` sales that have a customer
|
||||
* picked. Cash / card / EFT sales leave it blank forever.
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts
|
||||
*/
|
||||
import PocketBase from "pocketbase";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function loadEnvFile(name: string) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
||||
if (m && !process.env[m[1]]) {
|
||||
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fine — rely on shell env */
|
||||
}
|
||||
}
|
||||
loadEnvFile(".env");
|
||||
loadEnvFile(".env.local");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
||||
const email = process.env.PB_ADMIN_EMAIL;
|
||||
const password = process.env.PB_ADMIN_PASSWORD;
|
||||
if (!url || !email || !password) {
|
||||
throw new Error(
|
||||
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
|
||||
);
|
||||
}
|
||||
|
||||
const pb = new PocketBase(url);
|
||||
await pb.collection("_superusers").authWithPassword(email, password);
|
||||
console.log(`[pos-sales-delivery-note] Authenticated against ${url}`);
|
||||
|
||||
const deliveryNotes = await pb.collections.getOne("delivery_notes");
|
||||
const posSales = await pb.collections.getOne("pos_sales");
|
||||
|
||||
if (posSales.fields.some((f: { name: string }) => f.name === "delivery_note")) {
|
||||
console.log("[pos-sales-delivery-note] pos_sales.delivery_note already present — nothing to do.");
|
||||
return;
|
||||
}
|
||||
|
||||
await pb.collections.update(posSales.id, {
|
||||
fields: [
|
||||
...posSales.fields,
|
||||
{
|
||||
name: "delivery_note",
|
||||
type: "relation",
|
||||
required: false,
|
||||
presentable: false,
|
||||
// PB stores relation options FLAT at the top level of the
|
||||
// field object, not nested under `options` — verified
|
||||
// against the live schema by inspecting
|
||||
// `orders.potential_quote`. The nested form is rejected
|
||||
// with "fields.N.collectionId Cannot be blank."
|
||||
collectionId: deliveryNotes.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
console.log("[pos-sales-delivery-note] Added pos_sales.delivery_note.");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[pos-sales-delivery-note] FAILED:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pdf, type DocumentProps } from "@react-pdf/renderer";
|
||||
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { buildPrintBundle } from "@/app/(app)/invoicing/print-bundle";
|
||||
|
|
@ -183,10 +183,13 @@ async function handlePrint(input: { deliveryNoteNumber: string; origin: string }
|
|||
}),
|
||||
};
|
||||
});
|
||||
const slipDoc = PickingSlipPdf({ header: slipHeader, lines: slipLines });
|
||||
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
|
||||
const slipString = await slipInstance.toString();
|
||||
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
|
||||
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
|
||||
// renders uncompressed and re-encodes the PDF as latin1, which mangles
|
||||
// binary streams (raster images arrive embedded but unpainted). See the
|
||||
// note at the top of `src/lib/pdf/render.tsx`.
|
||||
const slipBytes = new Uint8Array(
|
||||
await renderToBuffer(PickingSlipPdf({ header: slipHeader, lines: slipLines }) as React.ReactElement<DocumentProps>),
|
||||
);
|
||||
|
||||
// Same bundle shape as the invoice print: duplicate the DN so the
|
||||
// operator gets an office copy + customer copy in one print, then
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock, f
|
|||
const fetchMock = vi.fn();
|
||||
const getFullList = vi.fn();
|
||||
const buildPrintBundle = vi.fn();
|
||||
const pdfMock = vi.fn(() => ({
|
||||
toString: vi.fn().mockResolvedValue("SLIP_PDF"),
|
||||
}));
|
||||
// The route renders the picking slip via `renderToBuffer()`, not
|
||||
// `pdf(doc).toString()` — see the note at the top of
|
||||
// `src/lib/pdf/render.tsx`. Returns real bytes.
|
||||
const pdfMock = vi.fn().mockResolvedValue(Buffer.from("SLIP_PDF"));
|
||||
const pickingSlipPdfMock = vi.fn((_props: unknown) => "PICKING_SLIP_DOC");
|
||||
// Mirror the PB SDK's files.getURL: the caller passes
|
||||
// { collectionId, id } and a filename, the SDK returns
|
||||
|
|
@ -43,7 +44,7 @@ vi.mock("@/app/(app)/invoicing/print-bundle", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@react-pdf/renderer", () => ({
|
||||
pdf: pdfMock,
|
||||
renderToBuffer: pdfMock,
|
||||
Document: () => null,
|
||||
Page: () => null,
|
||||
Text: () => null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pdf, type DocumentProps } from "@react-pdf/renderer";
|
||||
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { buildPrintBundle } from "@/app/(app)/invoicing/print-bundle";
|
||||
|
|
@ -164,10 +164,15 @@ async function handlePrintBundle(input: { invoiceNumber: string }) {
|
|||
};
|
||||
});
|
||||
|
||||
const slipDoc = PickingSlipPdf({ header, lines });
|
||||
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
|
||||
const slipString = await slipInstance.toString();
|
||||
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
|
||||
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
|
||||
// renders uncompressed and re-encodes the PDF as latin1, which mangles
|
||||
// binary streams (raster images arrive embedded but unpainted). See the
|
||||
// note at the top of `src/lib/pdf/render.tsx`.
|
||||
const slipBytes = new Uint8Array(
|
||||
await renderToBuffer(
|
||||
PickingSlipPdf({ header, lines }) as React.ReactElement<DocumentProps>,
|
||||
),
|
||||
);
|
||||
|
||||
const combined = await buildPrintBundle(invoiceBytes, slipBytes);
|
||||
const out = Buffer.from(combined);
|
||||
|
|
|
|||
101
src/app/api/v1/delivery-notes/resolve/route.ts
Normal file
101
src/app/api/v1/delivery-notes/resolve/route.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertBrowserSession } from "@/lib/server/browser-session";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/delivery-notes/resolve?number=DN26100401
|
||||
*
|
||||
* Turns a scanned delivery note NUMBER into the record the rest of the
|
||||
* API speaks in (a 15-char PB id).
|
||||
*
|
||||
* The QR on the printed note carries the human number, because that is
|
||||
* what the operator reads out and what the paper already shows. Every
|
||||
* existing route — detail, signed-copy, mark-invoiced, customer-po —
|
||||
* is keyed on the PB id, so the scanner needs this hop. It is the only
|
||||
* place a number becomes an id, which keeps that translation in one
|
||||
* auditable spot instead of spreading a guess-the-key lookup over the
|
||||
* client and every action button.
|
||||
*
|
||||
* Returns a compact summary rather than the full detail read model:
|
||||
* the scanner card needs the header, the line count and the status (to
|
||||
* decide which ladder step to offer), not the quote history.
|
||||
*
|
||||
* Errors:
|
||||
* - 401 no browser session
|
||||
* - 400 missing / malformed `number`
|
||||
* - 404 no delivery note with that number
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await assertBrowserSession(req);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unauthorized";
|
||||
return NextResponse.json({ error: message }, { status: 401 });
|
||||
}
|
||||
|
||||
const raw = (req.nextUrl.searchParams.get("number") ?? "").trim().toUpperCase();
|
||||
if (!raw) {
|
||||
return NextResponse.json({ error: "number is required" }, { status: 400 });
|
||||
}
|
||||
// `DN` + 8-10 digits. Matches the validation in
|
||||
// `src/lib/scan/qr-detect.ts`; the scanner checks first, this is the
|
||||
// server-side belt so a hand-rolled request can't put a PB filter
|
||||
// string in front of an unvalidated value.
|
||||
if (!/^DN\d{8,10}$/.test(raw)) {
|
||||
return NextResponse.json(
|
||||
{ error: `"${raw}" is not a delivery note number (expected DN followed by 8-10 digits).` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
// The number is regex-validated to DN + digits only, so it cannot
|
||||
// contain a quote or a PB filter operator.
|
||||
// Same collection name the service uses; it is a module-local
|
||||
// const there and not exported, so we repeat the literal.
|
||||
const page = await pb.collection("delivery_notes").getList<Record<string, unknown>>(
|
||||
1,
|
||||
1,
|
||||
{
|
||||
filter: `delivery_note_number = "${raw}"`,
|
||||
expand: "customer,company",
|
||||
},
|
||||
);
|
||||
const dn = page.items[0];
|
||||
if (!dn) {
|
||||
return NextResponse.json(
|
||||
{ error: `No delivery note found for ${raw}.` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const customer = (dn as { expand?: { customer?: { customer?: string } } }).expand?.customer;
|
||||
const company = (dn as { expand?: { company?: { company?: string } } }).expand?.company;
|
||||
const itemCount = await pb
|
||||
.collection("order_items")
|
||||
.getList(1, 1, { filter: `delivery_note_number = "${raw}"` })
|
||||
.then((p) => p.totalItems ?? p.items.length)
|
||||
.catch(() => 0);
|
||||
|
||||
return NextResponse.json({
|
||||
id: dn.id,
|
||||
delivery_note_number: dn.delivery_note_number,
|
||||
status: dn.status,
|
||||
customer_name: customer?.customer ?? "",
|
||||
company_name: company?.company ?? "",
|
||||
customer_po: dn.customer_po ?? "",
|
||||
delivery_date: dn.delivery_date ?? "",
|
||||
signed_copy: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "",
|
||||
item_count: itemCount,
|
||||
created: dn.created,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to resolve delivery note";
|
||||
const status = (error as Error & { status?: number }).status || 500;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ describe("POST /api/v1/pos/checkout", () => {
|
|||
items: [],
|
||||
stockErrors: [],
|
||||
duplicate: false,
|
||||
deliveryNote: null,
|
||||
});
|
||||
const res = await POST(
|
||||
checkoutRequest({
|
||||
|
|
@ -89,6 +90,7 @@ describe("POST /api/v1/pos/checkout", () => {
|
|||
items: [],
|
||||
stockErrors: [],
|
||||
duplicate: false,
|
||||
deliveryNote: null,
|
||||
});
|
||||
await POST(
|
||||
checkoutRequest({
|
||||
|
|
@ -110,6 +112,7 @@ describe("POST /api/v1/pos/checkout", () => {
|
|||
items: [],
|
||||
stockErrors: [],
|
||||
duplicate: false,
|
||||
deliveryNote: null,
|
||||
});
|
||||
await POST(
|
||||
checkoutRequest({
|
||||
|
|
@ -130,6 +133,7 @@ describe("POST /api/v1/pos/checkout", () => {
|
|||
items: [],
|
||||
stockErrors: [],
|
||||
duplicate: true,
|
||||
deliveryNote: null,
|
||||
});
|
||||
await POST(
|
||||
checkoutRequest({
|
||||
|
|
|
|||
130
src/app/api/v1/pos/delivery-note/[number]/pdf/route.ts
Normal file
130
src/app/api/v1/pos/delivery-note/[number]/pdf/route.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { assertPosAuth } from "@/lib/server/pos-auth";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/v1/pos/delivery-note/[number]/pdf
|
||||
*
|
||||
* Streams the A4 delivery-note PDF for a POS-raised account sale so
|
||||
* the till can print it on the office printer.
|
||||
*
|
||||
* This exists rather than pointing the till at the CRM's
|
||||
* `/api/invoicing/delivery-note-pdf` for two reasons:
|
||||
*
|
||||
* 1. Auth. Every `/api/v1/pos/*` route sits behind
|
||||
* `assertPosAuth`, and the till's bearer key is the only
|
||||
* credential it holds. The invoicing route has no auth check at
|
||||
* all, so leaning on it would mean the till printing from an
|
||||
* unauthenticated surface.
|
||||
* 2. Lookup key. This takes the `DN26090301` number the checkout
|
||||
* response handed back, so the till never has to carry a PB id.
|
||||
*
|
||||
* Storage model is unchanged: `createAdHocDeliveryNote` uploads the
|
||||
* rendered PDF to every `order_items.order_item_attachements` row
|
||||
* stamped with the DN number, so we find the token the same way the
|
||||
* invoicing route does.
|
||||
*
|
||||
* Errors:
|
||||
* - 401 when POS_API_KEY is missing/wrong
|
||||
* - 404 unknown DN number, or a DN with no saved PDF on file
|
||||
* - 502 when the upstream PocketBase file read fails
|
||||
*/
|
||||
type OrderItemRecord = {
|
||||
id: string;
|
||||
delivery_note_number?: string;
|
||||
order_item_attachements?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* PB's multi-file fields come back as a bare string when exactly one
|
||||
* file is attached and as an array otherwise. `createAdHocDeliveryNote`
|
||||
* writes one PDF per item, so both shapes are normal in the wild.
|
||||
*/
|
||||
function findPdfToken(
|
||||
items: OrderItemRecord[],
|
||||
): { itemId: string; token: string } | null {
|
||||
for (const item of items) {
|
||||
const raw = item.order_item_attachements;
|
||||
if (typeof raw === "string" && raw.length > 0) {
|
||||
return { itemId: item.id, token: raw };
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
// Prefer a .pdf entry — a DN item can also carry a supplier
|
||||
// invoice scan or a counter-sale photo, and printing one of
|
||||
// those as the delivery note would be worse than a 404.
|
||||
const pdf = raw.find((t) => typeof t === "string" && t.toLowerCase().endsWith(".pdf"));
|
||||
const token = pdf ?? raw.find((t) => typeof t === "string" && t.length > 0);
|
||||
if (typeof token === "string" && token.length > 0) {
|
||||
return { itemId: item.id, token };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ number: string }> },
|
||||
) {
|
||||
try {
|
||||
assertPosAuth(req);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unauthorized";
|
||||
return NextResponse.json({ error: message }, { status: 401 });
|
||||
}
|
||||
|
||||
const { number: rawNumber } = await params;
|
||||
const deliveryNoteNumber = (rawNumber || "").trim();
|
||||
if (!deliveryNoteNumber) {
|
||||
return NextResponse.json({ error: "Missing delivery note number" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = await getAdminPocketBase();
|
||||
const escaped = deliveryNoteNumber.replace(/"/g, '\\"');
|
||||
const items = await pb.collection("order_items").getFullList<OrderItemRecord>({
|
||||
filter: `delivery_note_number = "${escaped}"`,
|
||||
});
|
||||
if (items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `No delivery note found for ${deliveryNoteNumber}` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const found = findPdfToken(items);
|
||||
if (!found) {
|
||||
return NextResponse.json(
|
||||
{ error: `No saved PDF for ${deliveryNoteNumber}` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||
const fileUrl = `${base}/api/files/order_items/${found.itemId}/${encodeURIComponent(found.token)}`;
|
||||
const fileRes = await fetch(fileUrl);
|
||||
if (!fileRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream PocketBase file read failed (HTTP ${fileRes.status})` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const buf = Buffer.from(await fileRes.arrayBuffer());
|
||||
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${deliveryNoteNumber}.pdf"`,
|
||||
// The till prints straight after checkout; a cached PDF would
|
||||
// be a stale reprint of a re-raised note.
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
const { pdfMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoisted(() => {
|
||||
const { renderToBufferMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoisted(() => {
|
||||
return {
|
||||
pdfMock: vi.fn(() => ({
|
||||
toString: vi.fn().mockResolvedValue("STOCK_PICKING_SLIP"),
|
||||
})),
|
||||
// The route renders via `renderToBuffer()` (module-level helper),
|
||||
// not `pdf(doc).toString()` — see the note at the top of
|
||||
// `src/lib/pdf/render.tsx` for why the toString path was removed.
|
||||
// It returns real bytes, so the mock hands back a real Buffer.
|
||||
renderToBufferMock: vi.fn().mockResolvedValue(Buffer.from("STOCK_PICKING_SLIP")),
|
||||
pickingSlipPdfMock: vi.fn((_props: unknown) => "STOCK_PICKING_SLIP_DOC"),
|
||||
getFirstListItem: vi.fn(),
|
||||
getFullList: vi.fn(),
|
||||
|
|
@ -13,7 +15,7 @@ const { pdfMock, pickingSlipPdfMock, getFirstListItem, getFullList } = vi.hoiste
|
|||
});
|
||||
|
||||
vi.mock("@react-pdf/renderer", () => ({
|
||||
pdf: pdfMock,
|
||||
renderToBuffer: renderToBufferMock,
|
||||
Document: () => null,
|
||||
Page: () => null,
|
||||
Text: () => null,
|
||||
|
|
@ -56,7 +58,7 @@ function makeGetRequest(purchaseNumber?: string): Request {
|
|||
|
||||
describe("GET /api/v1/stock-purchases/picking-slip", () => {
|
||||
beforeEach(() => {
|
||||
pdfMock.mockClear();
|
||||
renderToBufferMock.mockClear();
|
||||
pickingSlipPdfMock.mockReset();
|
||||
getFirstListItem.mockReset();
|
||||
getFullList.mockReset();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pdf, type DocumentProps } from "@react-pdf/renderer";
|
||||
import { renderToBuffer, type DocumentProps } from "@react-pdf/renderer";
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { PickingSlipPdf } from "@/app/(app)/invoicing/picking-slip";
|
||||
|
|
@ -149,10 +149,15 @@ export async function GET(request: NextRequest) {
|
|||
};
|
||||
});
|
||||
|
||||
const slipDoc = PickingSlipPdf({ header, lines: slipLines });
|
||||
const slipInstance = pdf(slipDoc as unknown as React.ReactElement<DocumentProps>);
|
||||
const slipString = await slipInstance.toString();
|
||||
const slipBytes = new Uint8Array(Buffer.from(slipString, "binary"));
|
||||
// `renderToBuffer()` — NOT `pdf(doc).toString()`. The toString path
|
||||
// renders uncompressed and re-encodes the PDF as latin1, which mangles
|
||||
// binary streams (raster images arrive embedded but unpainted). See the
|
||||
// note at the top of `src/lib/pdf/render.tsx`.
|
||||
const slipBytes = new Uint8Array(
|
||||
await renderToBuffer(
|
||||
PickingSlipPdf({ header, lines: slipLines }) as React.ReactElement<DocumentProps>,
|
||||
),
|
||||
);
|
||||
|
||||
const filename = `${purchase.purchase_number ?? purchaseNumber}-picking-slip.pdf`;
|
||||
return new NextResponse(slipBytes, {
|
||||
|
|
|
|||
24
src/app/scan/layout.tsx
Normal file
24
src/app/scan/layout.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
|
||||
/**
|
||||
* /scan is its own route group (outside `(app)`) so it gets a
|
||||
* dedicated PWA manifest — "add to home screen" should give the
|
||||
* operator a scanner, not the full CRM. Mirrors the pattern already
|
||||
* used by `(counter)` for `/manifest-counter.json`.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: "DN Scanner · WFDASH",
|
||||
manifest: "/manifest-scan.json",
|
||||
applicationName: "WFDASH DN Scanner",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#4C96D7",
|
||||
};
|
||||
|
||||
export default function ScanLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
549
src/app/scan/page.tsx
Normal file
549
src/app/scan/page.tsx
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* /scan — delivery-note scanner.
|
||||
*
|
||||
* Standalone (outside the `(app)` route group) so it gets a clean
|
||||
* full-bleed layout: a scanner has no business in a CRM sidebar, and
|
||||
* the operator using it is often holding a phone.
|
||||
*
|
||||
* Flow: point the camera at the QR printed on a delivery note → the
|
||||
* note is resolved → offer the three things you actually do at the
|
||||
* counter: reprint, photograph the signed copy, or move it toward
|
||||
* invoicing.
|
||||
*
|
||||
* The QR carries the human number (`DN26100401`), so the first call is
|
||||
* `resolve`, which maps number → PB id. Everything after that speaks
|
||||
* ids, which is what every existing route already takes.
|
||||
*
|
||||
* STATUS LADDER — the actions offered depend on where the note sits.
|
||||
* `mark-invoiced` hard-refuses anything that isn't `po_received`, and
|
||||
* `customer-po` refuses a note that isn't `sent`/`loaded`. So rather
|
||||
* than offering a button that 500s, each step only appears when it is
|
||||
* legal, and the card shows which one comes next:
|
||||
*
|
||||
* draft ──load──▶ loaded ──PO──▶ po_received ──invoice──▶ invoiced
|
||||
* └─(sent via quote)──▶ sent ──PO──▶ po_received ──▶ ...
|
||||
*
|
||||
* `loaded` is used rather than `sent` because `draft → sent` means
|
||||
* "create a quote and email it" — a side effect a warehouse scan must
|
||||
* never trigger silently. `load-dn` is the honest "the van has this
|
||||
* paperwork" step.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Camera,
|
||||
CameraOff,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Loader2,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import getPocketBase from "@/lib/pocketbase";
|
||||
import {
|
||||
detectScannerSupport,
|
||||
parseDeliveryNoteNumber,
|
||||
scanVideoFrames,
|
||||
type QrDetectorKind,
|
||||
} from "@/lib/scan/qr-detect";
|
||||
|
||||
type Resolved = {
|
||||
id: string;
|
||||
delivery_note_number: string;
|
||||
status: string;
|
||||
customer_name: string;
|
||||
company_name: string;
|
||||
customer_po: string;
|
||||
delivery_date: string;
|
||||
signed_copy: string;
|
||||
item_count: number;
|
||||
};
|
||||
|
||||
type Toast = { kind: "ok" | "err"; text: string } | null;
|
||||
|
||||
const STATUS_COPY: Record<string, string> = {
|
||||
draft: "Draft — not yet loaded",
|
||||
sent: "Sent to customer",
|
||||
loaded: "Loaded — with the driver",
|
||||
po_received: "Customer PO received",
|
||||
invoiced: "Invoiced",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
export default function ScanPage() {
|
||||
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const streamRef = React.useRef<MediaStream | null>(null);
|
||||
const abortRef = React.useRef<AbortController | null>(null);
|
||||
const fileRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [support, setSupport] = React.useState<{
|
||||
kind: QrDetectorKind;
|
||||
camera: boolean;
|
||||
secureContext: boolean;
|
||||
} | null>(null);
|
||||
const [cameraOn, setCameraOn] = React.useState(false);
|
||||
const [manual, setManual] = React.useState("");
|
||||
const [note, setNote] = React.useState<Resolved | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [toast, setToast] = React.useState<Toast>(null);
|
||||
const [poDraft, setPoDraft] = React.useState("");
|
||||
const [showPoBox, setShowPoBox] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSupport(detectScannerSupport());
|
||||
}, []);
|
||||
|
||||
// Stop the camera on unmount. Leaving a getUserMedia stream running
|
||||
// keeps the camera light on after the operator navigates away.
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort();
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const flash = React.useCallback((kind: "ok" | "err", text: string) => {
|
||||
setToast({ kind, text });
|
||||
window.setTimeout(() => setToast(null), 6000);
|
||||
}, []);
|
||||
|
||||
const stopCamera = React.useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
setCameraOn(false);
|
||||
}, []);
|
||||
|
||||
const api = React.useCallback(
|
||||
async (path: string, init?: RequestInit) => {
|
||||
const token = getPocketBase().authStore.token;
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init?.headers ?? {}),
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body as unknown;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lookup = React.useCallback(
|
||||
async (number: string) => {
|
||||
setBusy("lookup");
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/v1/delivery-notes/resolve?number=${encodeURIComponent(number)}`,
|
||||
);
|
||||
setNote(data as Resolved);
|
||||
setPoDraft((data as Resolved).customer_po ?? "");
|
||||
setShowPoBox(false);
|
||||
stopCamera();
|
||||
flash("ok", `${number} loaded`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Lookup failed";
|
||||
setNote(null);
|
||||
flash("err", msg);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[api, flash, stopCamera],
|
||||
);
|
||||
|
||||
const startCamera = React.useCallback(async () => {
|
||||
setToast(null);
|
||||
if (!support?.camera) {
|
||||
flash("err", "This device has no camera available to the browser.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: "environment" } },
|
||||
audio: false,
|
||||
});
|
||||
streamRef.current = stream;
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!video || !canvas) return;
|
||||
video.srcObject = stream;
|
||||
video.setAttribute("playsinline", "true");
|
||||
await video.play();
|
||||
setCameraOn(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
void scanVideoFrames(
|
||||
video,
|
||||
canvas,
|
||||
(raw) => {
|
||||
const dn = parseDeliveryNoteNumber(raw);
|
||||
if (dn) void lookup(dn);
|
||||
else flash("err", `Scanned a code that isn't a delivery note: ${raw.slice(0, 40)}`);
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
flash("err", `Camera failed to start: ${msg}`);
|
||||
}
|
||||
}, [support, flash, lookup]);
|
||||
|
||||
const runAction = React.useCallback(
|
||||
async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
flash("err", err instanceof Error ? err.message : "Action failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[flash],
|
||||
);
|
||||
|
||||
const status = note?.status ?? "";
|
||||
|
||||
// Ladder helpers — each mirrors the server's own guards so the UI
|
||||
// never offers a button the API will refuse.
|
||||
const canLoad = status === "draft" || status === "sent";
|
||||
const canPo = status === "sent" || status === "loaded";
|
||||
const canInvoice = status === "po_received";
|
||||
const isDead = status === "cancelled" || status === "completed";
|
||||
const nextStep = canLoad
|
||||
? "Load this note for the driver"
|
||||
: canPo
|
||||
? "Capture the customer's PO number"
|
||||
: canInvoice
|
||||
? "Ready to send to Invoicing"
|
||||
: isDead
|
||||
? "This note is closed"
|
||||
: "No action needed right now";
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col gap-5 px-4 py-6">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Delivery note scanner</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the QR on a printed delivery note
|
||||
</p>
|
||||
</div>
|
||||
{note ? (
|
||||
<Button variant="outline" size="sm" onClick={() => { setNote(null); setPoDraft(""); }}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
Scan another
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{/* ---- the note card, or the scanner ---- */}
|
||||
{note ? (
|
||||
<section className="flex flex-col gap-4" data-testid="scan-note">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-mono text-lg font-semibold">{note.delivery_note_number}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{note.company_name || note.customer_name || "—"}
|
||||
</p>
|
||||
{note.customer_name ? (
|
||||
<p className="text-sm text-muted-foreground">{note.customer_name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold">
|
||||
{STATUS_COPY[status] ?? status}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Items</dt>
|
||||
<dd className="font-medium">{note.item_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">Customer PO</dt>
|
||||
<dd className="font-medium">{note.customer_po || "—"}</dd>
|
||||
</div>
|
||||
{note.signed_copy ? (
|
||||
<div className="col-span-2">
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Signed copy
|
||||
</dt>
|
||||
<dd className="truncate font-medium text-emerald-700">{note.signed_copy}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">{nextStep}</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("reprint", async () => {
|
||||
// Opens in a new tab: an <iframe> would need a
|
||||
// browser PDF viewer to print from, and Chrome backs
|
||||
// those with an extension that ignores print().
|
||||
const w = window.open(
|
||||
`/api/invoicing/delivery-note-pdf?deliveryNoteNumber=${encodeURIComponent(
|
||||
note.delivery_note_number,
|
||||
)}`,
|
||||
"_blank",
|
||||
);
|
||||
if (!w) flash("err", "Pop-up blocked — allow pop-ups to reprint.");
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "reprint" ? <Loader2 className="animate-spin" /> : <FileText />}
|
||||
Reprint delivery note
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy !== null || isDead}
|
||||
>
|
||||
{busy === "signed" ? <Loader2 className="animate-spin" /> : <Camera />}
|
||||
Photograph / upload signed copy
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
className="hidden"
|
||||
data-testid="scan-signed-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
void runAction("signed", async () => {
|
||||
// NOTE: deliberately the upload-only endpoint, NOT
|
||||
// /api/v1/deliveries/mark-signed-copy. That one flips
|
||||
// the note to `completed` in the same write, which
|
||||
// would jump a note straight past invoicing.
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
await api(`/api/v1/delivery-notes/${note.id}/signed-copy`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
flash("ok", "Signed copy uploaded");
|
||||
await lookup(note.delivery_note_number);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{canLoad ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("load", async () => {
|
||||
await api("/api/v1/deliveries/load-dn", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deliveryNoteId: note.id }),
|
||||
});
|
||||
flash("ok", "Marked as loaded");
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "load" ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
Mark as loaded (for the driver)
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canPo ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
onClick={() => setShowPoBox((v) => !v)}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<QrCode className="h-4 w-4" />
|
||||
Capture customer PO number
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{showPoBox ? (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-muted/30 p-3">
|
||||
<Label htmlFor="scan-po" className="text-sm">
|
||||
Customer PO number
|
||||
</Label>
|
||||
<Input
|
||||
id="scan-po"
|
||||
value={poDraft}
|
||||
onChange={(e) => setPoDraft(e.target.value)}
|
||||
placeholder="e.g. PO-88213"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!poDraft.trim() || busy !== null}
|
||||
onClick={() =>
|
||||
runAction("po", async () => {
|
||||
await api(`/api/v1/delivery-notes/${note.id}/customer-po`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customer_po: poDraft.trim() }),
|
||||
});
|
||||
flash("ok", "Customer PO saved");
|
||||
setShowPoBox(false);
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
>
|
||||
{busy === "po" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
|
||||
Save PO
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowPoBox(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canInvoice ? (
|
||||
<Button
|
||||
className="h-12"
|
||||
onClick={() =>
|
||||
runAction("invoice", async () => {
|
||||
await api(`/api/v1/delivery-notes/${note.id}/mark-invoiced`, {
|
||||
method: "POST",
|
||||
});
|
||||
flash("ok", "Moved to ready to invoice");
|
||||
await lookup(note.delivery_note_number);
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
{busy === "invoice" ? <Loader2 className="animate-spin" /> : <CheckCircle2 />}
|
||||
Move to ready to invoice
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-xl border bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
data-testid="scan-video"
|
||||
/>
|
||||
{!cameraOn ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<QrCode className="h-10 w-10 text-white/60" />
|
||||
<p className="text-sm text-white/80">
|
||||
{support?.camera
|
||||
? "Camera is off"
|
||||
: "This browser will not give the page a camera"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-[18%] rounded-xl border-2 border-white/70" />
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{support && !support.secureContext ? (
|
||||
<p className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
This page is not on HTTPS, so the browser blocks the camera. Use the office
|
||||
network's secure address, or type the number below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{cameraOn ? (
|
||||
<Button variant="outline" className="h-12 flex-1" onClick={stopCamera}>
|
||||
<CameraOff className="mr-1 h-4 w-4" />
|
||||
Stop camera
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="h-12 flex-1"
|
||||
onClick={startCamera}
|
||||
disabled={!support?.camera}
|
||||
>
|
||||
<Camera className="mr-1 h-4 w-4" />
|
||||
Start camera
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<Label htmlFor="scan-manual" className="text-sm">
|
||||
Or type the delivery note number
|
||||
</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Input
|
||||
id="scan-manual"
|
||||
value={manual}
|
||||
onChange={(e) => setManual(e.target.value.toUpperCase())}
|
||||
placeholder="DN26100401"
|
||||
className="font-mono"
|
||||
data-testid="scan-manual-input"
|
||||
/>
|
||||
<Button
|
||||
disabled={!parseDeliveryNoteNumber(manual) || busy !== null}
|
||||
onClick={() => {
|
||||
const dn = parseDeliveryNoteNumber(manual);
|
||||
if (dn) void lookup(dn);
|
||||
}}
|
||||
>
|
||||
{busy === "lookup" ? <Loader2 className="animate-spin" /> : null}
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Works on any device, camera or not.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{toast ? (
|
||||
<div
|
||||
data-testid="scan-toast"
|
||||
className={`fixed inset-x-3 bottom-3 z-50 mx-auto max-w-md rounded-lg px-4 py-3 text-sm shadow-lg ${
|
||||
toast.kind === "ok" ? "bg-emerald-600 text-white" : "bg-red-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{toast.text}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,8 +11,9 @@
|
|||
* The dialog is intentionally chrome-minimal so the PDF
|
||||
* takes most of the viewport. The footer has:
|
||||
* - Close
|
||||
* - Print (calls iframe.contentWindow.print() so the
|
||||
* operator prints just the PDF, not the whole page)
|
||||
* - Print (opens the PDF in a new tab — see the note on
|
||||
* `handlePrint` for why the iframe's own print() gives blank
|
||||
* pages)
|
||||
* - Download (uses the `download` attribute so the
|
||||
* browser saves the file rather than opening it in a
|
||||
* viewer)
|
||||
|
|
@ -67,28 +68,77 @@ export function PdfPreviewDialog({
|
|||
subtitle,
|
||||
downloadFilename,
|
||||
}: PdfPreviewDialogProps) {
|
||||
// Ref on the iframe so Print can call the iframe's own
|
||||
// print() — this prints just the PDF, not the whole page
|
||||
// (the parent page has the modal chrome which would also
|
||||
// print otherwise).
|
||||
// Ref on the iframe so we can tell whether the PDF has finished
|
||||
// loading before offering a print action.
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
|
||||
// Clear the pop-up-blocked notice on close rather than in an effect:
|
||||
// syncing state from an effect triggers a second render pass, and
|
||||
// the flag is only ever set by a click inside this component.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) setBlocked(false);
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
// Calling the iframe's contentWindow.print() prints the
|
||||
// PDF document inside the iframe. Works in Chrome, Edge,
|
||||
// Firefox, Safari. If the iframe didn't load (network
|
||||
// blip, broken PDF), the call is a no-op — the operator
|
||||
// can still use the Download button.
|
||||
try {
|
||||
iframeRef.current?.contentWindow?.print();
|
||||
} catch {
|
||||
// Ignore — some browsers throw on cross-origin or
|
||||
// blocked-iframe cases. The Download button remains.
|
||||
// Deliberately opens the PDF in a new tab rather than calling
|
||||
// `iframe.contentWindow.print()`.
|
||||
//
|
||||
// That older approach does NOT work for a PDF in Chrome. Chrome
|
||||
// renders PDFs with its internal `pdf_embedder` extension, not as
|
||||
// a real DOM document, so the iframe's `contentDocument` is an
|
||||
// empty shell:
|
||||
//
|
||||
// <html><head><link rel="stylesheet"
|
||||
// href="chrome-extension://…/pdf_embedder.css"></head>
|
||||
// <body></body></html>
|
||||
//
|
||||
// The PDF *paints* (you can see it), but `print()` runs against
|
||||
// that empty body — so the operator gets a print dialog full of
|
||||
// blank pages and no error to explain why. Verified in Chrome by
|
||||
// reading the iframe's document straight out of the page.
|
||||
//
|
||||
// A new tab hands the operator Chrome's real PDF viewer, whose own
|
||||
// print button works. Same document, no blank pages.
|
||||
const url = pdfUrl;
|
||||
const win = window.open(url, "_blank", "noopener,noreferrer");
|
||||
if (!win) {
|
||||
// Popup blocked. Say so rather than appearing to do nothing.
|
||||
setBlocked(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (blocked) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pop-up blocked</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your browser blocked the new tab needed to print {title}. Allow
|
||||
pop-ups for this site, or use Download to save the PDF and open it
|
||||
directly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<a href={pdfUrl} download={downloadFilename}>
|
||||
<Button type="button" variant="outline">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
Download instead
|
||||
</Button>
|
||||
</a>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
// Wide + tall — the PDF is the focus. Min-height is
|
||||
// important so the iframe can render at a useful size
|
||||
|
|
@ -109,7 +159,7 @@ export function PdfPreviewDialog({
|
|||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
aria-label="Close preview"
|
||||
data-testid="pdf-preview-close"
|
||||
>
|
||||
|
|
@ -137,14 +187,15 @@ export function PdfPreviewDialog({
|
|||
<DialogFooter className="px-4 py-3 border-t gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
data-testid="pdf-preview-close-footer"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
{/* Print — calls the iframe's own print() so the
|
||||
operator prints just the PDF, not the page
|
||||
chrome behind the modal. */}
|
||||
{/* Print — opens the PDF in a new tab so the operator gets
|
||||
Chrome's real PDF viewer, whose print button works. See
|
||||
the note on `handlePrint` for why the iframe's own
|
||||
`print()` produces blank pages. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -466,16 +517,10 @@ export function QuotePdfPreviewDialog({
|
|||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// Same approach as `PdfPreviewDialog`: print
|
||||
// the PDF inside the iframe so the operator
|
||||
// prints just the PDF, not the page chrome.
|
||||
try {
|
||||
iframeRef.current?.contentWindow?.print();
|
||||
} catch {
|
||||
// Ignore — some browsers throw on cross-
|
||||
// origin / blocked-iframe cases. The
|
||||
// Download button remains.
|
||||
}
|
||||
// Opens in a new tab, same as `handlePrint` above —
|
||||
// `contentWindow.print()` on a PDF iframe prints
|
||||
// Chrome's empty pdf_embedder shell, i.e. blank pages.
|
||||
if (pdfUrl) window.open(pdfUrl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
data-testid="quote-pdf-preview-print"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ import { X, Printer, Download, Loader2, ExternalLink } from "lucide-react";
|
|||
* - On mount, fetches the PDF binary from the API as a Blob, creates
|
||||
* a local blob URL, and renders it inside an `<iframe>` (browser
|
||||
* native PDF viewer).
|
||||
* - Print button: asks the iframe to call its own `window.print()` —
|
||||
* prints the PDF directly, not the surrounding page chrome.
|
||||
* - Print button: opens the PDF in a new tab. NOT
|
||||
* `iframe.contentWindow.print()` — Chrome renders PDFs with its
|
||||
* internal `pdf_embedder` extension, so the iframe's
|
||||
* `contentDocument` is an empty shell and `print()` against it
|
||||
* yields blank pages. Same bug and same fix as
|
||||
* `components/delivery-notes/preview-dialog.tsx`.
|
||||
* - Download button: fetches again with `?download=1` to get a
|
||||
* `Content-Disposition: attachment` response, triggers a save dialog.
|
||||
* - Open in new tab: opens the blob URL in a new browser tab for
|
||||
|
|
@ -99,13 +103,14 @@ export function QuotePdfViewer({
|
|||
if (!open) return null;
|
||||
|
||||
const handlePrint = () => {
|
||||
// Print from inside the iframe so only the PDF prints, not the modal chrome.
|
||||
try {
|
||||
iframeRef.current?.contentWindow?.print();
|
||||
} catch {
|
||||
// Some browsers block cross-frame print; fall back to opening the blob URL.
|
||||
if (blobUrl) window.open(blobUrl, "_blank");
|
||||
}
|
||||
// Open the blob URL in a new tab. Calling
|
||||
// `iframeRef.current?.contentWindow?.print()` here looks like the
|
||||
// obvious thing to do and is what this used to do, but Chrome
|
||||
// backs a PDF iframe with its `pdf_embedder` extension, so the
|
||||
// frame's document is an empty `<body>` — `print()` on it produces
|
||||
// a dialog full of blank pages. The new tab gets the real PDF
|
||||
// viewer, whose own print button works.
|
||||
if (blobUrl) window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
Moon,
|
||||
Package,
|
||||
Plus,
|
||||
QrCode,
|
||||
Settings as SettingsIcon,
|
||||
ShoppingCart,
|
||||
Sun,
|
||||
|
|
@ -119,6 +120,10 @@ const operationsItems: NavEntry[] = [
|
|||
// reference number. n8n will eventually auto-complete
|
||||
// these (docs/deliveries-n8n-workflow.md).
|
||||
{ href: "/deliveries", label: "Deliveries", icon: Truck },
|
||||
// Counter-side scanner for the printed delivery-note QR. Separate
|
||||
// standalone PWA at /scan (it declares its own manifest), linked
|
||||
// here so the office can find it from the CRM too.
|
||||
{ href: "/scan", label: "DN Scanner", icon: QrCode },
|
||||
];
|
||||
|
||||
// Stock is its own top-level group (not nested under Operations) so the
|
||||
|
|
|
|||
|
|
@ -147,11 +147,15 @@ export function defaultAccentFor(code: LetterheadCode): string {
|
|||
* return it as a `data:image/svg+xml;base64,...` URI suitable for
|
||||
* `<Image src={...}>` in @react-pdf.
|
||||
*
|
||||
* We embed SVG instead of the original raster (PNG/JPEG) logos because
|
||||
* @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug that
|
||||
* replaces every byte ≥ 0x80 in raster image data with `0xFD`. SVG is
|
||||
* text-based, so it sidesteps that code path — @react-pdf parses the
|
||||
* SVG XML and embeds vector primitives directly into the PDF stream.
|
||||
* The logo is embedded as SVG because `public/letterhead/<code>.svg`
|
||||
* IS an SVG source file — @react-pdf parses the XML and embeds vector
|
||||
* primitives. (An earlier version of this comment blamed a
|
||||
* "@react-pdf byte-corruption bug that replaces every byte ≥ 0x80 with
|
||||
* 0xFD" and used SVG as a workaround. The corruption was real but the
|
||||
* culprit was this app's own render pipeline — `pdf().toString()`
|
||||
* re-encodes the PDF as latin1, so U+FFFD replacement characters
|
||||
* truncated to 0xFD. Fixed by using `renderToBuffer()`; see the note
|
||||
* at the top of `lib/pdf/render.tsx`. Raster logos work again.)
|
||||
*
|
||||
* **Base64, not URL-encoded.** The bundled PDFKit only matches
|
||||
* `^data:.+?;base64,(.*)$` when decoding inline data URIs (see
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Document, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
|
||||
import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer";
|
||||
|
||||
import type { Letterhead } from "@/lib/letterhead";
|
||||
import { defaultAccentFor } from "@/lib/letterhead";
|
||||
import { formatDateLong } from "./format";
|
||||
import type { QrMatrix } from "./qr";
|
||||
|
||||
export type DeliveryNoteDocumentHeader = {
|
||||
/** Server-generated delivery note number, e.g. "DN26071401". */
|
||||
|
|
@ -26,14 +27,52 @@ export type DeliveryNoteDocumentProps = {
|
|||
lines: DeliveryNoteDocumentLine[];
|
||||
letterhead: Letterhead;
|
||||
/**
|
||||
* Logo as a `data:image/svg+xml;charset=utf-8,...` URI. Same SVG
|
||||
* workaround as the quote + PO documents — @react-pdf v4.5.1
|
||||
* corrupts every byte ≥ 0x80 in raster image data, but SVG is
|
||||
* text and goes through the SVG renderer instead.
|
||||
* Logo as a `data:image/svg+xml;base64,...` URI.
|
||||
*
|
||||
* The SVG here is because `public/letterhead/*.svg` IS an SVG source
|
||||
* file (see `loadLetterheadLogoDataUri`), not because raster data
|
||||
* needs wrapping. An earlier version of this comment claimed
|
||||
* "@react-pdf v4.5.1 corrupts every byte >= 0x80 in raster image
|
||||
* data" and recommended wrapping rasters in `<svg><image>`. That
|
||||
* claim was wrong, and the workaround is actively harmful: a PNG
|
||||
* wrapped in `<svg><image>` renders as an EMPTY box with no error,
|
||||
* while the same PNG passed directly draws correctly. See the note
|
||||
* at the top of `render.tsx` for what was actually going on.
|
||||
*/
|
||||
logoDataUri: string | null;
|
||||
/**
|
||||
* Captured signature as a `data:image/png;base64,...` URI, drawn
|
||||
* into the "Received By" block. Null/absent leaves the blank rule —
|
||||
* so every existing caller is unaffected.
|
||||
*
|
||||
* PNG, passed directly. Verified by rendering all three encodings
|
||||
* through this exact document: a base64 PNG data URI draws
|
||||
* correctly, a pure-vector SVG draws correctly, and a PNG *wrapped
|
||||
* inside* `<svg><image>` renders as an empty box with no error.
|
||||
*/
|
||||
signatureImage?: string | null;
|
||||
/**
|
||||
* QR code drawn as vector modules, bottom-right of the page.
|
||||
* Null/absent omits it entirely.
|
||||
*
|
||||
* Carries the delivery note number so a scanner app can resolve the
|
||||
* note. See `src/lib/pdf/qr.ts` for the security note — the value
|
||||
* is a sequential, enumerable number and this is only appropriate
|
||||
* for a closed internal consumer.
|
||||
*/
|
||||
qr?: QrMatrix | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Physical size of the printed QR, in points.
|
||||
*
|
||||
* 28pt across a 21-module code plus the 4-module quiet zone is ~0.96pt
|
||||
* (~0.34mm) per module including margin. Consumer scanners want
|
||||
* ~0.5mm per module, so this is comfortable without crowding the
|
||||
* signature block.
|
||||
*/
|
||||
const QR_SIZE_PT = 28;
|
||||
|
||||
/**
|
||||
* Renders a printable delivery note PDF. Same visual language as the
|
||||
* quote + PO documents (letterhead top-left, logo top-right, big
|
||||
|
|
@ -49,6 +88,8 @@ export function DeliveryNoteDocument({
|
|||
lines,
|
||||
letterhead,
|
||||
logoDataUri,
|
||||
signatureImage = null,
|
||||
qr = null,
|
||||
}: DeliveryNoteDocumentProps) {
|
||||
const accent =
|
||||
letterhead.accent_color ||
|
||||
|
|
@ -171,7 +212,18 @@ export function DeliveryNoteDocument({
|
|||
<View style={styles.signatureRow}>
|
||||
<View style={styles.signatureCol}>
|
||||
<Text style={styles.signatureTitle}>Received By</Text>
|
||||
<View style={styles.signatureLine} />
|
||||
{/* Drawn signature, when one was captured. Replaces the
|
||||
blank rule rather than sitting on it — a blank line
|
||||
under a signature reads as "unsigned" at a glance,
|
||||
which is the opposite of what this block is for. */}
|
||||
{signatureImage ? (
|
||||
<>
|
||||
<Image style={styles.signatureImage} src={signatureImage} />
|
||||
<View style={styles.signatureRule} />
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.signatureLine} />
|
||||
)}
|
||||
<Text style={styles.signatureLabel}>Name & signature</Text>
|
||||
<View style={styles.signatureLineShort} />
|
||||
<Text style={styles.signatureLabel}>Date</Text>
|
||||
|
|
@ -192,6 +244,42 @@ export function DeliveryNoteDocument({
|
|||
{letterhead.footer_text ? (
|
||||
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
|
||||
) : null}
|
||||
|
||||
{/* QR, bottom-right. Vector modules, not an image — see
|
||||
`src/lib/pdf/qr.ts` for why that matters. The quiet zone is
|
||||
baked into the Svg viewBox so scanners see the light margin
|
||||
the spec requires even though the surrounding page has the
|
||||
accent-coloured table nearby. */}
|
||||
{qr ? (
|
||||
<View style={styles.qrBlock}>
|
||||
<Svg
|
||||
width={QR_SIZE_PT}
|
||||
height={QR_SIZE_PT}
|
||||
viewBox={`0 0 ${qr.size + qr.quietZone * 2} ${qr.size + qr.quietZone * 2}`}
|
||||
>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={qr.size + qr.quietZone * 2}
|
||||
height={qr.size + qr.quietZone * 2}
|
||||
fill="#ffffff"
|
||||
/>
|
||||
{qr.modules.map((dark, i) =>
|
||||
dark ? (
|
||||
<Rect
|
||||
key={i}
|
||||
x={(i % qr.size) + qr.quietZone}
|
||||
y={Math.floor(i / qr.size) + qr.quietZone}
|
||||
width={1}
|
||||
height={1}
|
||||
fill="#000000"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Svg>
|
||||
<Text style={styles.qrLabel}>Scan for delivery note</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
|
|
@ -305,6 +393,27 @@ const styles = StyleSheet.create({
|
|||
height: 22,
|
||||
marginBottom: 2,
|
||||
},
|
||||
/* Signature sits ON the rule rather than above it — the captured
|
||||
PNG is transparent-background, so the line reads as the baseline
|
||||
it was drawn against. Fixed height so a short scribble and a
|
||||
long one both leave the same "Name & signature" position, which
|
||||
keeps both columns aligned. */
|
||||
/* The captured PNG is transparent-background, so the rule reads as
|
||||
the baseline it was drawn against. Height is fixed so a short
|
||||
scribble and a long one leave "Name & signature" in exactly the
|
||||
same place — the two columns stay aligned and the page doesn't
|
||||
reflow when one is signed and the other isn't. */
|
||||
signatureImage: { width: 130, height: 22 },
|
||||
/* Zero-height rule for the signed case: the image supplies the 22pt
|
||||
of vertical space and this draws only the 1pt baseline directly
|
||||
under it. A full-height `signatureLine` here would leave the
|
||||
signature floating 22pt above the line it belongs on. */
|
||||
signatureRule: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#222",
|
||||
height: 0,
|
||||
marginBottom: 2,
|
||||
},
|
||||
signatureLineShort: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#222",
|
||||
|
|
@ -313,6 +422,23 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 2,
|
||||
},
|
||||
signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 },
|
||||
/* QR sits absolute in the bottom-right of the page body, above the
|
||||
absolutely-positioned footer text. 28pt on a 21-module code is
|
||||
~0.96pt per module plus the 4-module quiet zone — comfortably
|
||||
above the ~0.5mm/module that consumer scanners need, while
|
||||
staying small enough not to crowd the signature block. */
|
||||
qrBlock: {
|
||||
position: "absolute",
|
||||
right: 40,
|
||||
bottom: 40,
|
||||
alignItems: "center",
|
||||
},
|
||||
qrLabel: {
|
||||
fontSize: 5.5,
|
||||
color: "#94a3b8",
|
||||
marginTop: 2,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: "#94a3b8",
|
||||
|
|
|
|||
207
src/lib/pdf/delivery-note-qr.test.tsx
Normal file
207
src/lib/pdf/delivery-note-qr.test.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import jsQR from "jsqr";
|
||||
import zlib from "node:zlib";
|
||||
import { buildQrMatrix } from "./qr";
|
||||
import { renderDeliveryNotePdf } from "./render";
|
||||
import { DeliveryNoteDocument } from "./delivery-note-document";
|
||||
import type { Letterhead } from "@/lib/letterhead";
|
||||
|
||||
/**
|
||||
* The delivery-note QR is only useful if a phone can actually read it
|
||||
* off a printed page, so this decodes the matrix back to a string
|
||||
* rather than eyeballing a screenshot.
|
||||
*
|
||||
* Two things are proven separately:
|
||||
* 1. the MATRIX is a valid, decodable QR — round-tripped through a
|
||||
* real decoder (jsQR) at several rendered scales, including the
|
||||
* physical size the document actually prints at;
|
||||
* 2. the DOCUMENT actually ships that matrix — asserted against the
|
||||
* PDF's own content stream.
|
||||
*
|
||||
* Deliberately no poppler/pdftoppm dependency: rasterising a PDF needs
|
||||
* a binary that CI may not have, and a test that only runs on your
|
||||
* machine is a test that quietly stops running.
|
||||
*/
|
||||
|
||||
const letterhead = {
|
||||
id: "lh1",
|
||||
collectionId: "pbc_lh",
|
||||
collectionName: "letterhead_settings",
|
||||
created: "2026-06-22T20:31:27.165Z",
|
||||
updated: "2026-06-22T20:31:27.165Z",
|
||||
display_name: "WESTSCOTT GROUP OF COMPANIES",
|
||||
address: "47 Station Street",
|
||||
phone: "+27 18 786 2897",
|
||||
email: "quotes@wfsales.co.za",
|
||||
vat_number: "4840229449",
|
||||
vat_percent: 15,
|
||||
registration_number: "2014/004024/07",
|
||||
bank_name: "Standard Bank",
|
||||
bank_account_number: "022196552",
|
||||
bank_branch_code: "016141",
|
||||
terms: "",
|
||||
default_markup_percent: 30,
|
||||
footer_text: "Thank you for your business.",
|
||||
logo: "",
|
||||
is_active: true,
|
||||
company: "CNL Mining Supplies (Pty) Ltd",
|
||||
accent_color: "#f5b301",
|
||||
} as unknown as Letterhead;
|
||||
|
||||
/** Rasterise a QR matrix to RGBA pixels, mirroring the document's
|
||||
* white-background / black-module rendering. */
|
||||
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number) {
|
||||
const span = qr.size + qr.quietZone * 2;
|
||||
const dim = span * modulePx;
|
||||
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
|
||||
for (let i = 0; i < qr.modules.length; i++) {
|
||||
if (!qr.modules[i]) continue;
|
||||
const mx = (i % qr.size) + qr.quietZone;
|
||||
const my = Math.floor(i / qr.size) + qr.quietZone;
|
||||
for (let dy = 0; dy < modulePx; dy++) {
|
||||
for (let dx = 0; dx < modulePx; dx++) {
|
||||
const px = mx * modulePx + dx;
|
||||
const py = my * modulePx + dy;
|
||||
const o = (py * dim + px) * 4;
|
||||
data[o] = 0;
|
||||
data[o + 1] = 0;
|
||||
data[o + 2] = 0;
|
||||
data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width: dim, height: dim };
|
||||
}
|
||||
|
||||
describe("delivery-note QR matrix", () => {
|
||||
it("round-trips a delivery note number through a real decoder", () => {
|
||||
const number = "DN26100401";
|
||||
const qr = buildQrMatrix(number);
|
||||
// 4 px per module is a generous stand-in for a 200 dpi scan.
|
||||
const img = rasterise(qr, 4);
|
||||
const decoded = jsQR(img.data, img.width, img.height);
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded?.data).toBe(number);
|
||||
}, 30_000);
|
||||
|
||||
it("still decodes at the module size the document actually prints at", () => {
|
||||
// 28pt across 29 module-spans (21 + 2x4 quiet) at 200 dpi:
|
||||
// 28 / 72 * 200 = 77.8 px -> 77.8 / 29 = 2.68 px per module.
|
||||
// So 2 px/module is the pessimistic end of the real print size, and
|
||||
// it must decode. 1 px/module is NOT asserted: at 29x29 px total the
|
||||
// symbol is below what any decoder can resolve, and pretending
|
||||
// otherwise would be asserting something untrue about the format.
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
for (const px of [2, 3, 4, 6]) {
|
||||
const img = rasterise(qr, px);
|
||||
const decoded = jsQR(img.data, img.width, img.height);
|
||||
expect(
|
||||
decoded?.data,
|
||||
`failed to decode at ${px}px per module`,
|
||||
).toBe("DN26100401");
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("round-trips the longest plausible delivery note number", () => {
|
||||
// DN + YY + MM + DD + NN, with NN able to reach 3 digits on a busy
|
||||
// day. Encoding the realistic maximum proves we don't sit on a
|
||||
// version boundary that needs a bigger symbol than we assume.
|
||||
const qr = buildQrMatrix("DN261004999");
|
||||
const img = rasterise(qr, 4);
|
||||
expect(jsQR(img.data, img.width, img.height)?.data).toBe("DN261004999");
|
||||
}, 30_000);
|
||||
|
||||
it("refuses an empty string rather than emitting an unscannable symbol", () => {
|
||||
expect(() => buildQrMatrix(" ")).toThrow();
|
||||
});
|
||||
|
||||
it("keeps the 4-module quiet zone the spec requires", () => {
|
||||
expect(buildQrMatrix("DN26100401").quietZone).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delivery-note PDF carries the QR", () => {
|
||||
const baseProps = {
|
||||
header: {
|
||||
deliveryNoteNumber: "DN26100401",
|
||||
dateIso: "2026-10-04",
|
||||
orderNumber: "",
|
||||
companyName: "CNL Industrial Mining Supplies",
|
||||
customerName: "David Els",
|
||||
},
|
||||
lines: [
|
||||
{ id: "l1", description: "M12 X 50 BOLT BLK", qty: 4 },
|
||||
{ id: "l2", description: "M16 X 55 BOLT BLK", qty: 2 },
|
||||
],
|
||||
letterhead,
|
||||
logoDataUri: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Count SVG fill operations in the PDF's content streams.
|
||||
*
|
||||
* `re` (PDF rectangle) is the wrong operator to count: react-pdf
|
||||
* renders `<Svg>` children as SVG paths and emits an `f` (fill) per
|
||||
* shape, not a `re`. Verified by inflating the stream — 234 dark
|
||||
* modules land as 237 `f` ops (the extra three are the white quiet
|
||||
* zone plate plus two table row backgrounds).
|
||||
*/
|
||||
function countSvgFills(buf: Buffer): number {
|
||||
const s = buf.toString("latin1");
|
||||
let total = 0;
|
||||
let idx = 0;
|
||||
for (;;) {
|
||||
const i = s.indexOf("/FlateDecode", idx);
|
||||
if (i === -1) break;
|
||||
const dictEnd = s.indexOf(">>", i) + 2;
|
||||
const dict = s.slice(Math.max(0, i - 300), dictEnd);
|
||||
const lenMatch = /\/Length (\d+)/.exec(dict);
|
||||
let st = dictEnd;
|
||||
while (s[st] === "\n" || s[st] === "\r" || s[st] === " ") st++;
|
||||
if (s.slice(st, st + 6) === "stream") st += 6;
|
||||
while (s[st] === "\n" || s[st] === "\r") st++;
|
||||
const length = lenMatch ? Number(lenMatch[1]) : 200;
|
||||
try {
|
||||
const text = zlib.inflateSync(buf.subarray(st, st + length)).toString("latin1");
|
||||
total += (text.match(/\bf\b/g) || []).length;
|
||||
} catch {
|
||||
/* not a content stream */
|
||||
}
|
||||
idx = st + length;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
it("emits a vector fill for every dark module", async () => {
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
// Without the QR the document has only a handful of fills (table
|
||||
// row backgrounds, the white quiet-zone plate). Asserting the count
|
||||
// is at least the dark-module count therefore genuinely fails if the
|
||||
// QR is dropped — which a "the PDF is non-empty" check would not.
|
||||
const darkModules = qr.modules.filter(Boolean).length;
|
||||
expect(countSvgFills(buf)).toBeGreaterThanOrEqual(darkModules);
|
||||
}, 30_000);
|
||||
|
||||
it("renders the document fine with no QR at all", async () => {
|
||||
// Note this asserts on the DOCUMENT, not on
|
||||
// `renderDeliveryNotePdf` — the render layer always builds a QR and
|
||||
// passes it in as `qr={qr}` AFTER `{...props}`, so a caller's
|
||||
// `qr: null` is deliberately overridden. (An earlier version of
|
||||
// this test called the renderer and therefore silently tested the
|
||||
// QR-present path while claiming to test the absent one.) The
|
||||
// document tolerating a null QR is the thing that matters: it is
|
||||
// what makes the renderer's `try/catch` a safe degradation rather
|
||||
// than a promise it can't keep.
|
||||
const buf = await renderToBuffer(<DeliveryNoteDocument {...baseProps} />);
|
||||
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
|
||||
expect(buf.length).toBeGreaterThan(1024);
|
||||
// And it really is the smaller document.
|
||||
const withQr = await renderToBuffer(
|
||||
<DeliveryNoteDocument {...baseProps} qr={buildQrMatrix("DN26100401")} />,
|
||||
);
|
||||
expect(withQr.length).toBeGreaterThan(buf.length);
|
||||
}, 30_000);
|
||||
});
|
||||
77
src/lib/pdf/qr.ts
Normal file
77
src/lib/pdf/qr.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import QRCode from "qrcode";
|
||||
|
||||
/**
|
||||
* QR matrix builder for documents printed by `@react-pdf/renderer`.
|
||||
*
|
||||
* ## Why the matrix, and not a PNG
|
||||
*
|
||||
* A QR could be embedded as an image, but a matrix renders as pure
|
||||
* VECTOR — one `<Rect>` per dark module — which is strictly better
|
||||
* here:
|
||||
*
|
||||
* - it stays crisp at any print size or DPI, with no resampling
|
||||
* - it adds ~30 shapes rather than a compressed image blob, so the
|
||||
* PDF stays small
|
||||
* - it does not depend on react-pdf's raster image path at all.
|
||||
* That path was exactly what the old `pdf().toString()` pipeline
|
||||
* silently corrupted (see the note at the top of `render.tsx`),
|
||||
* and vector content is immune to it.
|
||||
*
|
||||
* ## What the QR carries
|
||||
*
|
||||
* Currently just the delivery note number, e.g. `DN26100401`.
|
||||
*
|
||||
* SECURITY NOTE — this is a deliberate product decision, not an
|
||||
* oversight. The number is a daily sequential counter (`DN{YY}{MM}{DD}{NN}`
|
||||
* starting at 01), so it is fully enumerable: anyone can guess every
|
||||
* delivery note that will ever exist. A QR carrying the bare number,
|
||||
* pointed at an upload endpoint, is therefore a signature-forgery
|
||||
* vector.
|
||||
*
|
||||
* That is acceptable *today* because the intended consumer is a
|
||||
* closed, logged-in internal app on the office network, not the public
|
||||
* internet. If this QR is ever exposed to customers, or the scan
|
||||
* target is ever made reachable without a login, the fix is to encode
|
||||
* an unguessable per-note token instead — e.g.
|
||||
* `DN26100401.<base64url(HMAC-SHA256(noteId, SIGNING_SECRET))[0..22]>`,
|
||||
* which needs no new column because it is recomputed rather than
|
||||
* stored. Raise it before the surface changes, not after.
|
||||
*/
|
||||
export type QrMatrix = {
|
||||
/** Modules per side, excluding the quiet zone. A version-1 code is 21. */
|
||||
size: number;
|
||||
/** Row-major, length `size * size`. `true` = dark module. */
|
||||
modules: boolean[];
|
||||
/**
|
||||
* QR spec requires 4 modules of light margin on every side, and
|
||||
* scanners rely on it. Without it a code sitting against other ink
|
||||
* on the page fails to decode, so the padding is part of the
|
||||
* contract, not decoration.
|
||||
*/
|
||||
quietZone: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode `text` as a QR matrix.
|
||||
*
|
||||
* Error correction `M` (~15%) is the deliberate middle: enough to keep
|
||||
* the code readable through a thermal-grade print, a fingerprint smear
|
||||
* and a bent-in-a-pocket scan, without inflating the module count (and
|
||||
* so the physical size) the way `H` would for a payload this short.
|
||||
*/
|
||||
export function buildQrMatrix(text: string): QrMatrix {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("buildQrMatrix: refusing to encode an empty string");
|
||||
}
|
||||
const qr = QRCode.create(trimmed, { errorCorrectionLevel: "M" });
|
||||
const size = qr.modules.size;
|
||||
// `qr.modules.data` is a FLAT row-major Uint8Array of size*size.
|
||||
// (It is not an array of rows — `data[r]` is a single module, which
|
||||
// is a trap worth naming.)
|
||||
const modules: boolean[] = new Array(size * size);
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
modules[i] = qr.modules.data[i] === 1;
|
||||
}
|
||||
return { size, modules, quietZone: 4 };
|
||||
}
|
||||
|
|
@ -62,20 +62,22 @@ export interface QuoteDocumentProps {
|
|||
items: QuoteItem[];
|
||||
customer: Customer;
|
||||
letterhead: Letterhead;
|
||||
/** Logo as a `data:image/svg+xml;charset=utf-8,...` URI. We use SVG
|
||||
* instead of the original raster (PNG/JPEG) logos because
|
||||
* @react-pdf/renderer v4.5.1 has a confirmed byte-corruption bug
|
||||
* that replaces every byte ≥ 0x80 in raster image data with `0xFD`,
|
||||
* turning valid JPEGs (`0xFF 0xD8` SOI) into garbage. SVG is text-
|
||||
* based, so it sidesteps the bug entirely — the data URI is a plain
|
||||
* UTF-8 string and goes through @react-pdf's SVG renderer (which
|
||||
* parses XML and embeds vector paths), not the broken raster path.
|
||||
/** Logo as a `data:image/svg+xml;base64,...` URI.
|
||||
*
|
||||
* If we ever need a non-SVG logo again, the upstream fixes are:
|
||||
* - Upgrade @react-pdf past the byte-corruption regression
|
||||
* - Post-process with PDFKit's `doc.image()` (bypasses the bug)
|
||||
* - Rasterize the SVG→PNG with a server-side library and embed
|
||||
* the PNG via PDFKit the same way
|
||||
* SVG because `public/letterhead/*.svg` IS an SVG source file —
|
||||
* @react-pdf parses the XML and embeds vector primitives. (This
|
||||
* comment previously described a "@react-pdf v4.5.1 byte-corruption
|
||||
* bug that replaces every byte >= 0x80 with 0xFD, turning JPEG SOI
|
||||
* markers into garbage", and listed workarounds. The corruption was
|
||||
* real; the cause was this app's render pipeline, not @react-pdf —
|
||||
* `pdf().toString()` re-encoded the PDF as latin1, so U+FFFD
|
||||
* replacement chars truncated to 0xFD. Fixed by `renderToBuffer()`;
|
||||
* see the note at the top of `lib/pdf/render.tsx`. Raster logos
|
||||
* work again, and none of those workarounds are needed.)
|
||||
*
|
||||
* Base64, not `;charset=utf-8,<urlencoded>`: the bundled PDFKit
|
||||
* only matches `^data:.+?;base64,(.*)$` for inline data URIs and
|
||||
* silently drops anything else as a filesystem path.
|
||||
*/
|
||||
logoDataUri: string | null;
|
||||
/**
|
||||
|
|
|
|||
283
src/lib/pdf/render-image-pipeline.test.ts
Normal file
283
src/lib/pdf/render-image-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import zlib from "node:zlib";
|
||||
import { renderDeliveryNotePdf } from "./render";
|
||||
import type { Letterhead } from "@/lib/letterhead";
|
||||
|
||||
/**
|
||||
* Regression guard for the PDF byte pipeline.
|
||||
*
|
||||
* ## The bug this exists to stop coming back
|
||||
*
|
||||
* Every renderer used to do:
|
||||
*
|
||||
* const instance = pdf(<Doc {...props} />);
|
||||
* const pdfString = await instance.toString();
|
||||
* return Buffer.from(pdfString, "binary");
|
||||
*
|
||||
* The stated reason was that `toBuffer()` broke under jsdom. That was
|
||||
* true of `pdf(el).toBuffer()` — which is a misnamed STREAM getter — but
|
||||
* the fix chosen for it, `toString()`, was far worse than the problem:
|
||||
*
|
||||
* - `toString()` renders with `compress: false`, so every PDF this
|
||||
* app produced was uncompressed. react-pdf's own source says why:
|
||||
* "when rendering to string if compress=true the document is
|
||||
* blank". The method is flagged upstream as buggy (#2112, #2095).
|
||||
* - Round-tripping the PDF through a JS string and re-encoding as
|
||||
* latin1 mangles binary streams. Text is ASCII in the content
|
||||
* stream so it survives; a raster image is a compressed binary
|
||||
* blob that does not. The image object ends up embedded but is
|
||||
* never painted.
|
||||
*
|
||||
* It stayed latent because the only image in these documents is the
|
||||
* letterhead logo, and that logo is an SVG — SVG is text, so it
|
||||
* round-tripped perfectly and hid the whole thing. A signature is the
|
||||
* first genuinely raster asset this pipeline has to carry.
|
||||
*
|
||||
* You cannot "try toBuffer, fall back to toString" to fix this:
|
||||
* calling `toBuffer()` first consumes the render, and a subsequent
|
||||
* `toString()` on the same instance returns a document with the
|
||||
* content missing. One `pdf()` instance, one terminal call. The fix is
|
||||
* `renderToBuffer()`, which does the right thing in a single pass and
|
||||
* works under jsdom too.
|
||||
*
|
||||
* ## What these tests can and cannot prove
|
||||
*
|
||||
* The assertions below are deliberately things that are true in BOTH
|
||||
* Node and jsdom, because the vitest environment takes a different
|
||||
* react-pdf image path: under jsdom the embedded image's zlib stream
|
||||
* will not inflate, even though the exact same document renders a
|
||||
* perfect signature under plain Node (verified by rasterising the
|
||||
* output with `pdftoppm` and measuring the pixels). So we do NOT assert
|
||||
* that image streams inflate — that would be a test that passes in
|
||||
* production and fails in CI, or worse, one tuned until it is green
|
||||
* and proves nothing.
|
||||
*
|
||||
* Instead we pin the three environment-independent facts that changed:
|
||||
* the output is compressed, the content stream is valid, and an image
|
||||
* appears when (and only when) a signature is supplied.
|
||||
*/
|
||||
|
||||
const letterhead = {
|
||||
id: "lh1",
|
||||
collectionId: "pbc_lh",
|
||||
collectionName: "letterhead_settings",
|
||||
created: "2026-06-22T20:31:27.165Z",
|
||||
updated: "2026-06-22T20:31:27.165Z",
|
||||
display_name: "CNL Mining Supplies",
|
||||
address: "47 Station Street",
|
||||
phone: "+27 18 786 2897",
|
||||
email: "quotes@wfsales.co.za",
|
||||
vat_number: "4840229449",
|
||||
vat_percent: 15,
|
||||
registration_number: "2014/004024/07",
|
||||
bank_name: "Standard Bank",
|
||||
bank_account_number: "022196552",
|
||||
bank_branch_code: "016141",
|
||||
terms: "",
|
||||
default_markup_percent: 30,
|
||||
footer_text: "",
|
||||
logo: "",
|
||||
is_active: true,
|
||||
company: "CNL Mining Supplies (Pty) Ltd",
|
||||
accent_color: "#738224",
|
||||
} as unknown as Letterhead;
|
||||
|
||||
/* ---------- a tiny PNG encoder, so the test owns its own fixture ---------- */
|
||||
|
||||
function crc32(buf: Buffer): number {
|
||||
const table: number[] = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(typed));
|
||||
return Buffer.concat([len, typed, crc]);
|
||||
}
|
||||
|
||||
const SIG_W = 120;
|
||||
const SIG_H = 30;
|
||||
|
||||
/** 120x30 RGBA PNG — a black wave on a transparent background. */
|
||||
function makeSignaturePng(): Buffer {
|
||||
const raw = Buffer.alloc(SIG_H * (1 + SIG_W * 4));
|
||||
for (let y = 0; y < SIG_H; y++) {
|
||||
const rowStart = y * (1 + SIG_W * 4);
|
||||
raw[rowStart] = 0; // per-scanline filter: none
|
||||
for (let x = 0; x < SIG_W; x++) {
|
||||
const wave = 15 + Math.round(Math.sin((x / (SIG_W - 1)) * Math.PI * 4) * 8);
|
||||
const onStroke = Math.abs(y - wave) <= 2;
|
||||
const p = rowStart + 1 + x * 4;
|
||||
raw[p] = 0;
|
||||
raw[p + 1] = 0;
|
||||
raw[p + 2] = 0;
|
||||
raw[p + 3] = onStroke ? 255 : 0;
|
||||
}
|
||||
}
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(SIG_W, 0);
|
||||
ihdr.writeUInt32BE(SIG_H, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // colour type: RGBA
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
pngChunk("IHDR", ihdr),
|
||||
pngChunk("IDAT", zlib.deflateSync(raw)),
|
||||
pngChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every FlateDecode stream in the PDF and report whether it
|
||||
* inflates. Returns one entry per stream, tagged image vs content.
|
||||
*
|
||||
* Hand-rolled rather than pulling in a PDF library: the whole point is
|
||||
* to answer "are these bytes intact", and a parser that can say "this
|
||||
* one will not inflate" is exactly the signal being asserted.
|
||||
*/
|
||||
function scanFlateStreams(
|
||||
buf: Buffer,
|
||||
): Array<{ kind: "image" | "content"; inflates: boolean }> {
|
||||
const s = buf.toString("latin1");
|
||||
const out: Array<{ kind: "image" | "content"; inflates: boolean }> = [];
|
||||
let idx = 0;
|
||||
for (;;) {
|
||||
const i = s.indexOf("/FlateDecode", idx);
|
||||
if (i === -1) break;
|
||||
const dictEnd = s.indexOf(">>", i) + 2;
|
||||
const dict = s.slice(Math.max(0, i - 300), dictEnd);
|
||||
const kind: "image" | "content" = /\/Subtype\s*\/Image/.test(dict) ? "image" : "content";
|
||||
const lenMatch = /\/Length (\d+)/.exec(dict);
|
||||
let st = dictEnd;
|
||||
while (s[st] === "\n" || s[st] === "\r" || s[st] === " ") st++;
|
||||
if (s.slice(st, st + 6) === "stream") st += 6;
|
||||
while (s[st] === "\n" || s[st] === "\r") st++;
|
||||
const length = lenMatch ? Number(lenMatch[1]) : 200;
|
||||
const raw = buf.subarray(st, st + length);
|
||||
let inflates = true;
|
||||
try {
|
||||
zlib.inflateSync(raw);
|
||||
} catch {
|
||||
inflates = false;
|
||||
}
|
||||
out.push({ kind, inflates });
|
||||
idx = st + length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Width/Height of every image XObject in the PDF. */
|
||||
function imageGeometry(buf: Buffer): Array<{ width: number; height: number }> {
|
||||
const s = buf.toString("latin1");
|
||||
const out: Array<{ width: number; height: number }> = [];
|
||||
let idx = 0;
|
||||
for (;;) {
|
||||
const i = s.indexOf("/Subtype /Image", idx);
|
||||
if (i === -1) break;
|
||||
const dict = s.slice(i, s.indexOf(">>", i) + 2);
|
||||
const w = /\/Width (\d+)/.exec(dict);
|
||||
const h = /\/Height (\d+)/.exec(dict);
|
||||
idx = i + 1;
|
||||
if (w && h) out.push({ width: Number(w[1]), height: Number(h[1]) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size of the same document through the old `pdf().toString()` path,
|
||||
* measured when this test was written. Used as the ceiling in the
|
||||
* compression assertion.
|
||||
*/
|
||||
const OLD_UNCOMPRESSED_BYTES = 12710;
|
||||
|
||||
const baseProps = {
|
||||
header: {
|
||||
deliveryNoteNumber: "DN26100301",
|
||||
dateIso: "2026-10-03",
|
||||
orderNumber: "",
|
||||
companyName: "Test Mining",
|
||||
customerName: "Ysterbaard",
|
||||
},
|
||||
lines: [
|
||||
{ id: "l1", description: "16mm Hex Bolt", qty: 10 },
|
||||
{ id: "l2", description: "16mm Hex Nut", qty: 10 },
|
||||
],
|
||||
letterhead,
|
||||
logoDataUri: null,
|
||||
};
|
||||
|
||||
describe("PDF byte pipeline", () => {
|
||||
it("returns a real Node Buffer, not a string round-trip", async () => {
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
|
||||
}, 30_000);
|
||||
|
||||
it("compresses its streams — the old toString() path produced none", async () => {
|
||||
// THE regression assertion. `pdf().toString()` renders with
|
||||
// compress:false, so the old output had zero /FlateDecode streams
|
||||
// and was ~4x larger. Measured on this document before it grew a
|
||||
// QR code:
|
||||
// old (toString) 12710 bytes, 0 Flate streams
|
||||
// new (renderToBuffer) 3149 bytes, compressed
|
||||
// The QR added ~2.5 KB of vector content since, so the ceiling is
|
||||
// expressed against the OLD baseline rather than a round number
|
||||
// picked when the document was smaller — an absolute byte count
|
||||
// is a tripwire that fires every time a feature is added, which
|
||||
// is how a real regression gets tuned away.
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
const streams = scanFlateStreams(buf);
|
||||
expect(streams.length).toBeGreaterThan(0);
|
||||
expect(buf.toString("latin1")).toContain("/FlateDecode");
|
||||
expect(buf.length).toBeLessThan(OLD_UNCOMPRESSED_BYTES);
|
||||
}, 30_000);
|
||||
|
||||
it("produces a content stream that inflates cleanly", async () => {
|
||||
// The latin1 round-trip also mangled binary streams — poppler
|
||||
// reported "Bad FCHECK in flate stream" on the old output. The
|
||||
// content stream is the one that is environment-independent, so
|
||||
// this is the check that the bytes actually arrive undamaged.
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
const content = scanFlateStreams(buf).filter((st) => st.kind === "content");
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
for (const st of content) {
|
||||
expect(st.inflates).toBe(true);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("embeds a raster image with the right geometry when a signature is supplied", async () => {
|
||||
const buf = await renderDeliveryNotePdf({
|
||||
...baseProps,
|
||||
signatureImage: `data:image/png;base64,${makeSignaturePng().toString("base64")}`,
|
||||
});
|
||||
const geometry = imageGeometry(buf);
|
||||
expect(geometry.length).toBeGreaterThan(0);
|
||||
for (const g of geometry) {
|
||||
expect(g.width).toBe(SIG_W);
|
||||
expect(g.height).toBe(SIG_H);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("embeds no image at all when no signature is supplied", async () => {
|
||||
// The negative control: if this ever fails, something is leaking
|
||||
// an image into every DN regardless of signature state.
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
expect(imageGeometry(buf)).toHaveLength(0);
|
||||
}, 30_000);
|
||||
|
||||
it("still renders a blank signature line with no signature", async () => {
|
||||
const buf = await renderDeliveryNotePdf({ ...baseProps });
|
||||
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
|
||||
expect(buf.length).toBeGreaterThan(1024);
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import zlib from "node:zlib";
|
||||
import {
|
||||
renderQuotePdf,
|
||||
renderDeliveryNotePdf,
|
||||
|
|
@ -17,6 +18,35 @@ import type { StockItem } from "@/lib/server/stock-items-service";
|
|||
* dep misconfig, etc.) trips the test even without eyeballing output.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Concatenate the inflated text of every FlateDecode stream in a PDF.
|
||||
*
|
||||
* Needed because `renderToBuffer()` compresses: text lives inside
|
||||
* zlib-compressed content streams, so scanning the raw buffer for a
|
||||
* string finds nothing. Any assertion of the form "this PDF does /
|
||||
* does not contain X" has to go through here or it is vacuous.
|
||||
*/
|
||||
function decompressPdfText(buf: Buffer): string {
|
||||
const out: string[] = [];
|
||||
const marker = Buffer.from("stream");
|
||||
let idx = buf.indexOf(marker);
|
||||
while (idx !== -1) {
|
||||
let start = idx + marker.length;
|
||||
// Skip the EOL after the `stream` keyword.
|
||||
if (buf[start] === 0x0d) start += 1;
|
||||
if (buf[start] === 0x0a) start += 1;
|
||||
const end = buf.indexOf(Buffer.from("endstream"), start);
|
||||
if (end === -1) break;
|
||||
try {
|
||||
out.push(zlib.inflateSync(buf.subarray(start, end)).toString("latin1"));
|
||||
} catch {
|
||||
// Not every stream is Flate (images may be raw or DCT); skip it.
|
||||
}
|
||||
idx = buf.indexOf(marker, end);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
const baseLetterhead: Letterhead = {
|
||||
id: "lh1",
|
||||
collectionId: "pbc_1010367695",
|
||||
|
|
@ -301,7 +331,13 @@ describe("renderDeliveryNotePdf", () => {
|
|||
letterhead: baseLetterhead,
|
||||
logoDataUri: null,
|
||||
});
|
||||
expect(buf.toString("latin1")).not.toContain("Supplier");
|
||||
// Grep the DECOMPRESSED text, not the raw bytes. The renderers
|
||||
// used to funnel through `pdf().toString()`, which renders
|
||||
// uncompressed — so a raw `buf.toString("latin1")` scan really did
|
||||
// see the text. `renderToBuffer()` compresses, so the same scan
|
||||
// would match nothing and this assertion would pass for the wrong
|
||||
// reason: a test that quietly stops testing is worse than no test.
|
||||
expect(decompressPdfText(buf)).not.toContain("Supplier");
|
||||
}, 30_000);
|
||||
it("renders with the ISHERWOOD letterhead code (no accent_color override)", async () => {
|
||||
const buf = await renderDeliveryNotePdf({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,52 @@
|
|||
import { pdf } from "@react-pdf/renderer";
|
||||
/**
|
||||
* Server-side PDF rendering. Every renderer here goes through
|
||||
* `renderToBuffer()` from `@react-pdf/renderer`.
|
||||
*
|
||||
* ## Why not `pdf(<Doc />).toString()`
|
||||
*
|
||||
* Every renderer used to funnel through
|
||||
*
|
||||
* const instance = pdf(<Doc {...props} />);
|
||||
* const pdfString = await instance.toString();
|
||||
* return Buffer.from(pdfString, "binary");
|
||||
*
|
||||
* on the belief that `toBuffer()` was unusable under jsdom (Vitest's
|
||||
* environment). That belief was half right and led somewhere much
|
||||
* worse than a test-only problem.
|
||||
*
|
||||
* Three things are going on, and only the first was ever real:
|
||||
*
|
||||
* 1. `pdf(el).toBuffer()` is NOT the buffer API. It resolves to the
|
||||
* PDFKit *stream* (react-pdf carries a TODO to rename it
|
||||
* `toStream`), and under jsdom it resolves to a bare
|
||||
* `PDFDocument` handle. That is the jsdom breakage the old
|
||||
* comment described — but it applies to that method, not to the
|
||||
* module-level `renderToBuffer()` helper, which collects the
|
||||
* stream into a real Node Buffer and works fine under jsdom.
|
||||
*
|
||||
* 2. `toString()` renders with `compress: false`. react-pdf's own
|
||||
* source says why: "when rendering to string if compress=true
|
||||
* the document is blank". So every PDF this app produced was
|
||||
* uncompressed — larger than necessary — and the method is
|
||||
* flagged upstream as buggy (react-pdf issues #2112, #2095).
|
||||
*
|
||||
* 3. The round-trip through a JS string corrupts binary streams.
|
||||
* A raster image (PNG/JPEG) arrives as a Flate-compressed binary
|
||||
* blob that does not survive being stringified and re-encoded as
|
||||
* latin1. The image object ends up embedded in the file but is
|
||||
* never painted. Text is ASCII in the content stream, so text
|
||||
* renders fine and the corruption is invisible until something
|
||||
* raster goes in — which is why this sat latent for so long. The
|
||||
* letterhead logo hid it completely: that logo is an SVG, and
|
||||
* SVG is text, so it round-tripped perfectly.
|
||||
*
|
||||
* `renderToBuffer()` fixes all three at once and needs no jsdom
|
||||
* fallback. `render-image-pipeline.test.ts` is the regression guard —
|
||||
* it renders a real raster image and asserts the bytes come back
|
||||
* intact, so this cannot silently come back.
|
||||
*/
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { buildQrMatrix, type QrMatrix } from "./qr";
|
||||
import { QuoteDocument, type QuoteDocumentProps } from "./quote-document";
|
||||
import {
|
||||
PurchaseOrderDocument,
|
||||
|
|
@ -34,23 +82,17 @@ import {
|
|||
/**
|
||||
* Server-side render of a `<QuoteDocument>` to a PDF Buffer.
|
||||
*
|
||||
* Uses `@react-pdf/renderer`'s Node API (`pdf(<JSX />).toBuffer()`) which
|
||||
* runs entirely in-process — no headless Chromium, no Playwright/Puppeteer,
|
||||
* no browser binary in the Docker image.
|
||||
* Runs entirely in-process via `renderToBuffer()` — no headless
|
||||
* Chromium, no Playwright/Puppeteer, no browser binary in the Docker
|
||||
* image. See the note at the top of this file for why that specific
|
||||
* API and not `pdf(<JSX />).toString()`.
|
||||
*
|
||||
* Always returns a fresh Buffer; never cached. Reads of letterhead,
|
||||
* quote, items, and customer happen in the API route before this is
|
||||
* called, so any edit in PB propagates on the next generation.
|
||||
*/
|
||||
export async function renderQuotePdf(props: QuoteDocumentProps): Promise<Buffer> {
|
||||
const instance = pdf(<QuoteDocument {...props} />);
|
||||
// `toBuffer()` works in pure Node, but under jsdom (Vitest's default
|
||||
// test environment) it returns a PDFKit document handle instead. We
|
||||
// funnel through `toString()` which always resolves to a string of
|
||||
// raw PDF bytes, then convert to a real Node Buffer. The string path
|
||||
// is ~the same performance as toBuffer in production.
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<QuoteDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,16 +107,12 @@ export async function renderQuotePdf(props: QuoteDocumentProps): Promise<Buffer>
|
|||
* the PO record itself — replaced 2026-07-06 the separate
|
||||
* `purchase_order_pdfs` collection).
|
||||
*
|
||||
* `toString()` is used for the same jsdom-test compat reason as the
|
||||
* quote renderer — both routes are test-covered and we want them to
|
||||
* produce the same shape regardless of environment.
|
||||
* Same `renderToBuffer()` pipeline as every other renderer here.
|
||||
*/
|
||||
export async function renderPurchaseOrderPdf(
|
||||
props: PurchaseOrderDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<PurchaseOrderDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<PurchaseOrderDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,10 +120,8 @@ export async function renderPurchaseOrderPdf(
|
|||
*
|
||||
* Stock POs are pure stock-replenishment orders with no customer in
|
||||
* the loop — the document layout is a slimmed-down version of the
|
||||
* customer-PO PDF (no Order Ref line, no "For: <customer>"). The
|
||||
* same `toString()` jsdom-compat pattern is used so the stock-PO
|
||||
* PDF route's tests run under the same setup as the other PDF
|
||||
* routes.
|
||||
* customer-PO PDF (no Order Ref line, no "For: <customer>"). Same
|
||||
* `renderToBuffer()` pipeline as the rest of this file.
|
||||
*
|
||||
* The route drops the resulting Buffer into
|
||||
* `stock_purchases.documents` (file, multi) + appends a metadata
|
||||
|
|
@ -97,9 +133,7 @@ export async function renderPurchaseOrderPdf(
|
|||
export async function renderStockPurchasePdf(
|
||||
props: StockPurchaseDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<StockPurchaseDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<StockPurchaseDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -115,9 +149,20 @@ export async function renderStockPurchasePdf(
|
|||
* the same item.
|
||||
*/
|
||||
export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): Promise<Buffer> {
|
||||
const instance = pdf(<DeliveryNoteDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
// Every delivery note carries a QR of its own number so the
|
||||
// warehouse/driver scanner app can resolve the note straight off
|
||||
// the printed page. Built here rather than in the document so the
|
||||
// QR encoder stays out of the presentational component.
|
||||
//
|
||||
// Non-fatal: a QR that fails to encode must never cost the operator
|
||||
// their delivery note. The document renders without it.
|
||||
let qr: QrMatrix | null = null;
|
||||
try {
|
||||
qr = buildQrMatrix(props.header.deliveryNoteNumber);
|
||||
} catch (err) {
|
||||
console.error("[renderDeliveryNotePdf] QR build failed:", err);
|
||||
}
|
||||
return renderToBuffer(<DeliveryNoteDocument {...props} qr={qr} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -135,25 +180,20 @@ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): P
|
|||
export async function renderRepairRequestPdf(
|
||||
props: RepairRequestDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<RepairRequestDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<RepairRequestDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side render of the stock-orders *list* PDF — every
|
||||
* purchase in one document, not one PDF per PO. Sibling of
|
||||
* `renderStockPurchasePdf` (which is the single-PO, supplier-facing
|
||||
* PDF). Both walk `@react-pdf/renderer`'s `pdf(<JSX />).toString()`
|
||||
* pipeline so the test suite runs under jsdom without dragging in
|
||||
* a Chromium binary.
|
||||
* PDF). Both use the same `renderToBuffer()` pipeline, which needs no
|
||||
* browser binary.
|
||||
*/
|
||||
export async function renderStockPurchaseListPdf(
|
||||
props: StockPurchaseListDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<StockPurchaseListDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<StockPurchaseListDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -163,9 +203,7 @@ export async function renderStockPurchaseListPdf(
|
|||
export async function renderRepPerformanceDetailPdf(
|
||||
props: RepPerformanceDetailDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<RepPerformanceDetailDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<RepPerformanceDetailDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -175,9 +213,7 @@ export async function renderRepPerformanceDetailPdf(
|
|||
export async function renderRepPerformanceTeamPdf(
|
||||
props: RepPerformanceTeamDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<RepPerformanceTeamDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<RepPerformanceTeamDocument {...props} />);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -189,7 +225,5 @@ export async function renderRepPerformanceTeamPdf(
|
|||
export async function renderStockItemStocktakePdf(
|
||||
props: StockItemStocktakeDocumentProps
|
||||
): Promise<Buffer> {
|
||||
const instance = pdf(<StockItemStocktakeDocument {...props} />);
|
||||
const pdfString = await instance.toString();
|
||||
return Buffer.from(pdfString, "binary");
|
||||
return renderToBuffer(<StockItemStocktakeDocument {...props} />);
|
||||
}
|
||||
|
|
@ -27,10 +27,11 @@ export type RepairRequestDocumentProps = {
|
|||
contactEmail: string;
|
||||
letterhead: Letterhead;
|
||||
/**
|
||||
* Logo as a `data:image/svg+xml;base64,...` URI. Same SVG workaround
|
||||
* as the quote / PO / DN documents — @react-pdf v4.5.1 corrupts every
|
||||
* byte ≥ 0x80 in raster image data, but SVG is text and goes through
|
||||
* the SVG renderer instead.
|
||||
* Logo as a `data:image/svg+xml;base64,...` URI. SVG because
|
||||
* `public/letterhead/*.svg` IS an SVG source file — this is not a
|
||||
* workaround for a react-pdf raster bug (there wasn't one; see the
|
||||
* note at the top of `lib/pdf/render.tsx` for what that mistaken
|
||||
* belief led to).
|
||||
*/
|
||||
logoDataUri: string | null;
|
||||
};
|
||||
|
|
|
|||
173
src/lib/scan/qr-detect.test.ts
Normal file
173
src/lib/scan/qr-detect.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildQrMatrix } from "@/lib/pdf/qr";
|
||||
import {
|
||||
isDeliveryNoteNumber,
|
||||
parseDeliveryNoteNumber,
|
||||
decodeQrFromCanvas,
|
||||
detectScannerSupport,
|
||||
} from "./qr-detect";
|
||||
|
||||
/**
|
||||
* The scanner is judged on one thing: does it read the QR that
|
||||
* `buildQrMatrix` actually puts on a delivery note? So the headline
|
||||
* tests are END-TO-END — encode with the production encoder,
|
||||
* rasterise, decode through the production fallback path, and assert
|
||||
* we get the delivery note number back.
|
||||
*
|
||||
* `BarcodeDetector` does not exist under jsdom, so these always
|
||||
* exercise the jsQR fallback. Deliberate: that is the path that has
|
||||
* to work on the devices the native API abandons (iOS, Firefox), and
|
||||
* it is the easy one to silently break.
|
||||
*/
|
||||
|
||||
type Raster = { data: Uint8ClampedArray; width: number; height: number };
|
||||
|
||||
/** Render a QR matrix to RGBA exactly as a canvas would hold it. */
|
||||
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number): Raster {
|
||||
const span = qr.size + qr.quietZone * 2;
|
||||
const dim = span * modulePx;
|
||||
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
|
||||
for (let i = 0; i < qr.modules.length; i++) {
|
||||
if (!qr.modules[i]) continue;
|
||||
const mx = (i % qr.size) + qr.quietZone;
|
||||
const my = Math.floor(i / qr.size) + qr.quietZone;
|
||||
for (let dy = 0; dy < modulePx; dy++) {
|
||||
for (let dx = 0; dx < modulePx; dx++) {
|
||||
const o = ((my * modulePx + dy) * dim + (mx * modulePx + dx)) * 4;
|
||||
data[o] = 0;
|
||||
data[o + 1] = 0;
|
||||
data[o + 2] = 0;
|
||||
data[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, width: dim, height: dim };
|
||||
}
|
||||
|
||||
function fakeCanvas(image: Raster): HTMLCanvasElement {
|
||||
return { width: image.width, height: image.height } as unknown as HTMLCanvasElement;
|
||||
}
|
||||
|
||||
function fakeCtx(image: Raster): CanvasRenderingContext2D {
|
||||
return {
|
||||
getImageData: () => ({ data: image.data, width: image.width, height: image.height }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
describe("parseDeliveryNoteNumber", () => {
|
||||
it("accepts a real delivery note number", () => {
|
||||
expect(parseDeliveryNoteNumber("DN26100401")).toBe("DN26100401");
|
||||
});
|
||||
|
||||
it("accepts a 3-digit day counter (a busy day)", () => {
|
||||
expect(parseDeliveryNoteNumber("DN261004123")).toBe("DN261004123");
|
||||
});
|
||||
|
||||
it("normalises case and surrounding whitespace", () => {
|
||||
expect(parseDeliveryNoteNumber(" dn26100401 \n")).toBe("DN26100401");
|
||||
});
|
||||
|
||||
it("rejects a random QR payload", () => {
|
||||
// A supplier's packing label or a pallet code must not reach the
|
||||
// network — these return null and the UI says "not a DN".
|
||||
expect(parseDeliveryNoteNumber("https://example.com/product/99281")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("ORDER-12345")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("mailto:accounts@wfsales.co.za")).toBeNull();
|
||||
expect(parseDeliveryNoteNumber("")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a DN-shaped string with a non-numeric body", () => {
|
||||
expect(parseDeliveryNoteNumber("DNabcdefgh")).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the number out of a scan URL", () => {
|
||||
// Lets a printed QR be re-pointed at a URL later without
|
||||
// reprinting every sheet already in circulation.
|
||||
expect(parseDeliveryNoteNumber("https://wfdash.example/scan?dn=DN26100401")).toBe(
|
||||
"DN26100401",
|
||||
);
|
||||
expect(parseDeliveryNoteNumber("https://wfdash.example/dn/DN26100401")).toBe(
|
||||
"DN26100401",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeliveryNoteNumber", () => {
|
||||
it("is a strict shape check", () => {
|
||||
// DN + YY(2) MM(2) DD(2) NN(2) = 8 digits is the normal shape.
|
||||
expect(isDeliveryNoteNumber("DN26100401")).toBe(true);
|
||||
// A busy day pushes the counter to 3 digits (9 total).
|
||||
expect(isDeliveryNoteNumber("DN261004001")).toBe(true);
|
||||
// Still accepted with a margin above that.
|
||||
expect(isDeliveryNoteNumber("DN2610040123")).toBe(true);
|
||||
// Too short — 7 digits.
|
||||
expect(isDeliveryNoteNumber("DN2610040")).toBe(false);
|
||||
// Too long — 11 digits.
|
||||
expect(isDeliveryNoteNumber("DN26100401234")).toBe(false);
|
||||
expect(isDeliveryNoteNumber("XX26100401")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeQrFromCanvas — end to end with the real delivery-note QR", () => {
|
||||
it("reads back the number the delivery-note PDF prints", async () => {
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
const image = rasterise(qr, 4);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
|
||||
}, 30_000);
|
||||
|
||||
it("reads a busy-day 3-digit number too", async () => {
|
||||
const qr = buildQrMatrix("DN261004123");
|
||||
const image = rasterise(qr, 4);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN261004123");
|
||||
}, 30_000);
|
||||
|
||||
it("still reads it at the pessimistic end of the printed size", async () => {
|
||||
// 2px/module ~ the real 200 dpi case. A camera at an angle is
|
||||
// worse, but if 2px fails on a clean frame the printed QR is too
|
||||
// small and that is worth catching here.
|
||||
const qr = buildQrMatrix("DN26100401");
|
||||
const image = rasterise(qr, 2);
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
image.width,
|
||||
image.height,
|
||||
);
|
||||
expect(parseDeliveryNoteNumber(raw ?? "")).toBe("DN26100401");
|
||||
}, 30_000);
|
||||
|
||||
it("returns null for a frame with no code (must not throw)", async () => {
|
||||
const blank = new Uint8ClampedArray(120 * 120 * 4).fill(255);
|
||||
const image: Raster = { data: blank, width: 120, height: 120 };
|
||||
const raw = await decodeQrFromCanvas(
|
||||
fakeCanvas(image),
|
||||
fakeCtx(image),
|
||||
120,
|
||||
120,
|
||||
);
|
||||
expect(raw).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("detectScannerSupport", () => {
|
||||
it("reports the jsQR fallback when BarcodeDetector is absent", () => {
|
||||
// jsdom has no BarcodeDetector — exactly the iOS/Firefox case.
|
||||
expect(detectScannerSupport().kind).toBe("fallback");
|
||||
});
|
||||
|
||||
it("does not throw when browser globals are missing (SSR/prerender)", () => {
|
||||
expect(() => detectScannerSupport()).not.toThrow();
|
||||
});
|
||||
});
|
||||
211
src/lib/scan/qr-detect.ts
Normal file
211
src/lib/scan/qr-detect.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import jsQR from "jsqr";
|
||||
|
||||
/**
|
||||
* QR detection for the /scan app.
|
||||
*
|
||||
* ## Why there are two detectors
|
||||
*
|
||||
* The native `BarcodeDetector` (Shape Detection API) is fast and
|
||||
* allocation-free, but it is **Chrome-only**. As of 2026 it is not
|
||||
* Baseline: Safari/iOS still has it "Under Consideration" (WebKit bug
|
||||
* #254573) and Firefox does not ship it at all. It is also
|
||||
* secure-context-only, so plain-http office LAN access silently
|
||||
* disables it.
|
||||
*
|
||||
* A scanner that only uses it simply does not work on an iPhone — and
|
||||
* a warehouse tablet is exactly the sort of device that might be one.
|
||||
* So: native where it exists, `jsQR` everywhere else. `jsQR` is pure
|
||||
* JS (no 2 MB WASM payload, which matters for a PWA on a weak signal),
|
||||
* and the delivery-note QR is always a QR code, so a QR-only decoder
|
||||
* is sufficient.
|
||||
*
|
||||
* Both paths run against the same `<canvas>` frame, so the caller
|
||||
* never branches.
|
||||
*/
|
||||
|
||||
export type QrDetectorKind = "native" | "fallback" | "none";
|
||||
|
||||
/**
|
||||
* Which detector this browser can actually run, and whether the
|
||||
* camera can be opened at all.
|
||||
*
|
||||
* `secureContext` and `mediaDevices` are reported separately from
|
||||
* `native` because a http:// origin loses BOTH the native detector
|
||||
* and — in most browsers — `getUserMedia` itself, and those need
|
||||
* different messages ("this device can't scan" vs "open it over
|
||||
* HTTPS").
|
||||
*/
|
||||
export function detectScannerSupport(): {
|
||||
kind: QrDetectorKind;
|
||||
camera: boolean;
|
||||
secureContext: boolean;
|
||||
} {
|
||||
const secureContext =
|
||||
typeof window !== "undefined" && window.isSecureContext !== false;
|
||||
const native =
|
||||
typeof window !== "undefined" && "BarcodeDetector" in window;
|
||||
const camera =
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices &&
|
||||
typeof navigator.mediaDevices.getUserMedia === "function";
|
||||
return {
|
||||
kind: native ? "native" : "fallback",
|
||||
camera,
|
||||
secureContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery note numbers are `DN{YY}{MM}{DD}{NN}` — DN + 8 digits, with
|
||||
* the day-counter able to run to 3 digits on a busy day.
|
||||
*
|
||||
* Validated BEFORE any network call so a random QR on a packing slip
|
||||
* (a supplier's, a pallet label's) can't fire a lookup, and so the
|
||||
* manual-entry box gives immediate feedback instead of a 404.
|
||||
*/
|
||||
const DN_PATTERN = /^DN\d{8,10}$/i;
|
||||
|
||||
export function isDeliveryNoteNumber(value: string): boolean {
|
||||
return DN_PATTERN.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw scanned string into a delivery note number, or null.
|
||||
*
|
||||
* Tolerates a URL that embeds the number, because a QR may be
|
||||
* re-pointed at a scan URL later without changing the printed paper —
|
||||
* `.../scan?dn=DN26100401` still resolves.
|
||||
*/
|
||||
export function parseDeliveryNoteNumber(raw: string): string | null {
|
||||
const text = (raw || "").trim();
|
||||
if (!text) return null;
|
||||
if (DN_PATTERN.test(text)) return text.toUpperCase();
|
||||
// A URL, or a path ending in the number.
|
||||
const fromUrl = /[?&]dn=([A-Za-z0-9]+)/.exec(text) ?? /\/([A-Za-z0-9]+)\/?$/.exec(text);
|
||||
const candidate = fromUrl?.[1] ?? "";
|
||||
return DN_PATTERN.test(candidate) ? candidate.toUpperCase() : null;
|
||||
}
|
||||
|
||||
type NativeDetector = {
|
||||
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
|
||||
};
|
||||
|
||||
let nativeDetector: NativeDetector | null = null;
|
||||
let nativeDetectorChecked = false;
|
||||
|
||||
async function getNativeDetector(): Promise<NativeDetector | null> {
|
||||
if (nativeDetectorChecked) return nativeDetector;
|
||||
nativeDetectorChecked = true;
|
||||
if (typeof window === "undefined" || !("BarcodeDetector" in window)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const Ctor = (window as unknown as {
|
||||
BarcodeDetector: {
|
||||
new (o: { formats: string[] }): NativeDetector;
|
||||
getSupportedFormats?: () => Promise<string[]>;
|
||||
};
|
||||
}).BarcodeDetector;
|
||||
const supported = await Ctor.getSupportedFormats?.();
|
||||
// Some builds expose the constructor but not QR in practice.
|
||||
// If we can query and QR is missing, don't use it.
|
||||
if (Array.isArray(supported) && !supported.includes("qr_code")) {
|
||||
return null;
|
||||
}
|
||||
nativeDetector = new Ctor({ formats: ["qr_code"] });
|
||||
} catch {
|
||||
nativeDetector = null;
|
||||
}
|
||||
return nativeDetector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one already-drawn canvas frame. Returns the raw QR text, or
|
||||
* null when the frame holds no readable code.
|
||||
*
|
||||
* `reuseInverted` is off deliberately: an inverted (white-on-black)
|
||||
* code is not something the delivery-note QR ever is, and leaving it
|
||||
* off measurably cuts false positives on printed labels.
|
||||
*/
|
||||
export async function decodeQrFromCanvas(
|
||||
canvas: HTMLCanvasElement,
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<string | null> {
|
||||
const native = await getNativeDetector();
|
||||
if (native) {
|
||||
try {
|
||||
const found = await native.detect(canvas);
|
||||
const value = found.find((b) => b.rawValue)?.rawValue;
|
||||
if (value) return value;
|
||||
// Fall THROUGH to jsQR rather than returning null — a native
|
||||
// miss on a blurred/angled frame is exactly when the JS decoder
|
||||
// sometimes still succeeds.
|
||||
} catch {
|
||||
/* fall through to the fallback */
|
||||
}
|
||||
}
|
||||
const image = ctx.getImageData(0, 0, width, height);
|
||||
const result = jsQR(image.data, width, height, { inversionAttempts: "dontInvert" });
|
||||
return result?.data ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the detector over a video stream until it finds something.
|
||||
*
|
||||
* Throttled to ~8 fps: a camera frame is 30-60 fps, and decoding every
|
||||
* one burns battery for no gain — the QR is not moving relative to the
|
||||
* page, so once per 120 ms is plenty. Rejects if `signal` aborts so
|
||||
* the caller can cancel cleanly on unmount or after a hit.
|
||||
*/
|
||||
export function scanVideoFrames(
|
||||
video: HTMLVideoElement,
|
||||
canvas: HTMLCanvasElement,
|
||||
onFound: (raw: string) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
return Promise.reject(new Error("Could not get a 2D context for scanning."));
|
||||
}
|
||||
const INTERVAL_MS = 120;
|
||||
return new Promise((resolve) => {
|
||||
let stopped = false;
|
||||
const finish = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => finish();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (stopped) return;
|
||||
if (signal.aborted) return finish();
|
||||
if (video.readyState < 2 || !video.videoWidth) return;
|
||||
// Scan a downscaled frame: cheaper to decode, and the code is
|
||||
// small on screen. 640px wide keeps it reliable.
|
||||
const scale = Math.min(1, 640 / video.videoWidth);
|
||||
const w = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
const h = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
if (canvas.height !== h) canvas.height = h;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
void decodeQrFromCanvas(canvas, ctx, w, h)
|
||||
.then((raw) => {
|
||||
if (raw && !stopped) {
|
||||
onFound(raw);
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* a single bad frame must not kill the loop */
|
||||
});
|
||||
}, INTERVAL_MS);
|
||||
|
||||
if (signal.aborted) finish();
|
||||
});
|
||||
}
|
||||
|
|
@ -947,6 +947,34 @@ export async function generateDeliveryNoteNumberServer(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Did this PocketBase create fail because the delivery note number
|
||||
* was already taken?
|
||||
*
|
||||
* PB surfaces a unique-index violation as a 400 with a per-field
|
||||
* `data` map, so the reliable check is "the error names our field"
|
||||
* rather than matching a message string that varies by SDK version.
|
||||
* The message is checked too as a fallback for the older shape where
|
||||
* the field map is absent.
|
||||
*
|
||||
* Kept narrow on purpose: anything that isn't recognisably a number
|
||||
* collision (a bad customer id, a PB outage) must propagate, or the
|
||||
* retry loop would swallow a real failure and retry it five times.
|
||||
*/
|
||||
function isDeliveryNoteNumberCollision(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const e = err as {
|
||||
message?: unknown;
|
||||
status?: unknown;
|
||||
response?: { status?: unknown; data?: unknown };
|
||||
data?: { [field: string]: { code?: string; message?: string } };
|
||||
};
|
||||
// PB's unique-violation code.
|
||||
if (e.data?.delivery_note_number?.code === "validation_invalid_unique") return true;
|
||||
const message = typeof e.message === "string" ? e.message : "";
|
||||
return /delivery_note_number/i.test(message) && /unique|already exists|violat/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of one line in the ad-hoc "create delivery note"
|
||||
* form. The customer + qty are required; supplier + prices are
|
||||
|
|
@ -1040,21 +1068,45 @@ export async function createAdHocDeliveryNote(
|
|||
}
|
||||
|
||||
// ── 2. Generate the DN number ─────────────────────────────────
|
||||
const deliveryNoteNumber = await generateDeliveryNoteNumberServer();
|
||||
let deliveryNoteNumber = await generateDeliveryNoteNumberServer();
|
||||
|
||||
// ── 3. Create the delivery_notes header ──────────────────────
|
||||
// The route layer is responsible for knowing the auth'd
|
||||
// user; the service just trusts `createdBy` (which is null
|
||||
// in tests / cron scenarios).
|
||||
const header = await pb.collection(DELIVERY_NOTES).create<DeliveryNote>({
|
||||
delivery_note_number: deliveryNoteNumber,
|
||||
customer: input.customer,
|
||||
company: input.company ?? null,
|
||||
order: null, // ad-hoc — no parent order
|
||||
status: "draft",
|
||||
notes: input.notes ?? "",
|
||||
created_by: input.createdBy ?? null,
|
||||
});
|
||||
//
|
||||
// Retried on a number collision. `generateDeliveryNoteNumberServer`
|
||||
// reads the highest existing number and adds one — that read-then-
|
||||
// write is not atomic, and PB holds a UNIQUE index on
|
||||
// `delivery_notes.delivery_note_number`. Two creates racing (the
|
||||
// POS now writes here too, alongside the CRM dialog) can therefore
|
||||
// pick the same number, and without this loop the loser gets a bare
|
||||
// 500 from the route for what is really a benign, self-healing
|
||||
// condition. PB reports this as a validation failure on the field.
|
||||
let header: DeliveryNote;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
header = await pb.collection(DELIVERY_NOTES).create<DeliveryNote>({
|
||||
delivery_note_number: deliveryNoteNumber,
|
||||
customer: input.customer,
|
||||
company: input.company ?? null,
|
||||
order: null, // ad-hoc — no parent order
|
||||
status: "draft",
|
||||
notes: input.notes ?? "",
|
||||
created_by: input.createdBy ?? null,
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
const isCollision =
|
||||
attempt < 5 &&
|
||||
isDeliveryNoteNumberCollision(err);
|
||||
if (!isCollision) throw err;
|
||||
console.warn(
|
||||
`[createAdHocDeliveryNote] ${deliveryNoteNumber} already taken — regenerating (attempt ${attempt + 1})`,
|
||||
);
|
||||
deliveryNoteNumber = await generateDeliveryNoteNumberServer();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Create the order_items rows ────────────────────────────
|
||||
// Each item is ad-hoc (no parent order) and stamped with the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { checkoutSale, type PosPb } from "./pos-service";
|
||||
import type { AdHocDeliveryNoteInput } from "./delivery-notes-service";
|
||||
|
||||
/**
|
||||
* Pure-logic tests for the discount math. Mocks out the PB
|
||||
|
|
@ -225,3 +226,240 @@ describe("checkoutSale — discount math", () => {
|
|||
expect(saleData.total).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Account sales now raise a SYSTEM delivery note server-side, inside
|
||||
* `checkoutSale`, and the 80mm thermal delivery-note slip is gone from
|
||||
* that path. These cover the three things that can go wrong:
|
||||
*
|
||||
* 1. a DN is raised for the wrong tender (or not at all for the
|
||||
* right one)
|
||||
* 2. the till's offline queue re-posts `/checkout` and creates a
|
||||
* SECOND delivery note for one physical delivery
|
||||
* 3. a DN failure takes the sale down with it
|
||||
*
|
||||
* `createDeliveryNote` is injected rather than real, because
|
||||
* `createAdHocDeliveryNote` opens its own admin PocketBase client and
|
||||
* a stubbed `PosPb` can't intercept it.
|
||||
*/
|
||||
describe("checkoutSale — account delivery note", () => {
|
||||
const STOCK = { id: "si-1", sku: "WID-1", cost_price: 50, current_qty: 100 };
|
||||
|
||||
type Fake = PosPb & {
|
||||
saleCreates: Array<Record<string, unknown>>;
|
||||
saleUpdates: Array<Record<string, unknown>>;
|
||||
setExisting: (row: Record<string, unknown> | null) => void;
|
||||
};
|
||||
|
||||
function accountPb(existing: Record<string, unknown> | null = null): Fake {
|
||||
const saleCreates: Array<Record<string, unknown>> = [];
|
||||
const saleUpdates: Array<Record<string, unknown>> = [];
|
||||
let existingRow = existing;
|
||||
const base = {
|
||||
getFullList: async () => [],
|
||||
getList: async () => ({ items: existingRow ? [existingRow] : [] }),
|
||||
getOne: async () => {
|
||||
throw new Error("404");
|
||||
},
|
||||
create: async (d: Record<string, unknown>) => ({ id: "x", ...d }),
|
||||
update: async (_id: string, d: Record<string, unknown>) => {
|
||||
saleUpdates.push(d);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
const pb = {
|
||||
collection: (name: string) => {
|
||||
if (name === "stock_items") {
|
||||
return { ...base, getFullList: async () => [STOCK], getList: async () => ({ items: [STOCK] }), getOne: async () => STOCK };
|
||||
}
|
||||
if (name === "pos_sales") {
|
||||
return {
|
||||
...base,
|
||||
create: async (d: Record<string, unknown>) => {
|
||||
saleCreates.push(d);
|
||||
return { id: "sale-1", ...d };
|
||||
},
|
||||
update: async (_id: string, d: Record<string, unknown>) => {
|
||||
saleUpdates.push(d);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (name === "pos_sale_items") {
|
||||
return { ...base, create: async (d: Record<string, unknown>) => ({ id: "line-1", ...d }) };
|
||||
}
|
||||
if (name === "stock_ledger") return { ...base, create: async () => ({ id: "led-1" }) };
|
||||
if (name === "customers") {
|
||||
return { ...base, getOne: async () => ({ id: "cust-1", company: "comp-1" }) };
|
||||
}
|
||||
if (name === "delivery_notes") {
|
||||
return { ...base, getOne: async () => ({ id: "dn-1", delivery_note_number: "DN26100301" }) };
|
||||
}
|
||||
return base;
|
||||
},
|
||||
};
|
||||
return Object.assign(pb as unknown as Fake, {
|
||||
saleCreates,
|
||||
saleUpdates,
|
||||
setExisting: (row: Record<string, unknown> | null) => {
|
||||
existingRow = row;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stubCreate() {
|
||||
// Typed with the input parameter so `.mock.calls[0][0]` is typed
|
||||
// rather than an empty tuple.
|
||||
return vi.fn(async (_input: AdHocDeliveryNoteInput) => ({
|
||||
id: "dn-1",
|
||||
delivery_note_number: "DN26100301",
|
||||
}));
|
||||
}
|
||||
|
||||
it("raises a delivery note for an account sale and stamps it on the sale", async () => {
|
||||
const pb = accountPb();
|
||||
const createDeliveryNote = stubCreate();
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{
|
||||
items: [{ stock_item_id: "si-1", qty: 2, unit_price: 115, sku: "WID-1", description: "Wedge" }],
|
||||
payment_method: "account",
|
||||
customer_id: "cust-1",
|
||||
},
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.deliveryNote?.delivery_note_number).toBe("DN26100301");
|
||||
// The pdfPath must be the POS-scoped route — the till's bearer key
|
||||
// is the only credential it holds, so it must not be pointed at the
|
||||
// CRM's unauthenticated invoicing route.
|
||||
expect(result.deliveryNote?.pdfPath).toBe("/delivery-note/DN26100301/pdf");
|
||||
|
||||
// Company is derived from the customer, never sent by the till.
|
||||
expect(createDeliveryNote).toHaveBeenCalledTimes(1);
|
||||
const arg = createDeliveryNote.mock.calls[0][0];
|
||||
expect(arg.customer).toBe("cust-1");
|
||||
expect(arg.company).toBe("comp-1");
|
||||
expect(arg.items).toEqual([
|
||||
expect.objectContaining({ description: "Wedge", quantity: 2, cost_price: 50, selling_price: 115, sku: "WID-1" }),
|
||||
]);
|
||||
|
||||
// The idempotency anchor is written, or a replay would re-create it.
|
||||
expect(pb.saleUpdates).toContainEqual({ delivery_note: "dn-1" });
|
||||
});
|
||||
|
||||
it("does NOT raise a delivery note for a cash sale", async () => {
|
||||
const pb = accountPb();
|
||||
const createDeliveryNote = stubCreate();
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{ items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }], payment_method: "cash" },
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(createDeliveryNote).not.toHaveBeenCalled();
|
||||
expect(result.deliveryNote).toBeNull();
|
||||
// (saleUpdates also carries the stock_items qty write, so assert on
|
||||
// the shape rather than the count.)
|
||||
expect(pb.saleUpdates.some((u) => "delivery_note" in u)).toBe(false);
|
||||
});
|
||||
|
||||
it("replays the EXISTING delivery note on a duplicate client_ref instead of making a second", async () => {
|
||||
// This is the offline-queue case: the sale banked, the response was
|
||||
// lost, and the till re-posts the same client_ref on reconnect.
|
||||
const pb = accountPb({
|
||||
id: "sale-1",
|
||||
client_ref: "local-abc",
|
||||
payment_method: "account",
|
||||
customer: "cust-1",
|
||||
delivery_note: "dn-1",
|
||||
});
|
||||
const createDeliveryNote = stubCreate();
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{
|
||||
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
|
||||
payment_method: "account",
|
||||
customer_id: "cust-1",
|
||||
client_ref: "local-abc",
|
||||
},
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.duplicate).toBe(true);
|
||||
expect(createDeliveryNote).not.toHaveBeenCalled();
|
||||
expect(result.deliveryNote?.delivery_note_number).toBe("DN26100301");
|
||||
});
|
||||
|
||||
it("retries the delivery note on a replay where the first attempt left it blank", async () => {
|
||||
// A sale whose DN creation failed leaves delivery_note empty. The
|
||||
// offline retry is the natural place to heal that.
|
||||
const pb = accountPb({
|
||||
id: "sale-1",
|
||||
client_ref: "local-abc",
|
||||
payment_method: "account",
|
||||
customer: "cust-1",
|
||||
delivery_note: "",
|
||||
});
|
||||
const createDeliveryNote = stubCreate();
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{
|
||||
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
|
||||
payment_method: "account",
|
||||
customer_id: "cust-1",
|
||||
client_ref: "local-abc",
|
||||
},
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.duplicate).toBe(true);
|
||||
expect(createDeliveryNote).toHaveBeenCalledTimes(1);
|
||||
expect(result.deliveryNote?.id).toBe("dn-1");
|
||||
});
|
||||
|
||||
it("keeps the sale when the delivery note cannot be created", async () => {
|
||||
const pb = accountPb();
|
||||
const createDeliveryNote = vi.fn(async () => {
|
||||
throw new Error("PocketBase is down");
|
||||
});
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{
|
||||
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
|
||||
payment_method: "account",
|
||||
customer_id: "cust-1",
|
||||
},
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
|
||||
// The sale and its stock movement are real — failing the checkout
|
||||
// would leave the drawer disagreeing with the stock ledger.
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.deliveryNote).toBeNull();
|
||||
expect(result.deliveryNoteError).toContain("PocketBase is down");
|
||||
expect(pb.saleCreates.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("flags an account sale with no customer rather than silently dropping the delivery note", async () => {
|
||||
const pb = accountPb();
|
||||
const createDeliveryNote = stubCreate();
|
||||
const result = await checkoutSale(
|
||||
pb,
|
||||
{ items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }], payment_method: "account" },
|
||||
{ createDeliveryNote },
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(createDeliveryNote).not.toHaveBeenCalled();
|
||||
expect(result.deliveryNote).toBeNull();
|
||||
expect(result.deliveryNoteError).toContain("no customer");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,25 @@ import type {
|
|||
PosSaleItem,
|
||||
StockItem,
|
||||
} from "@/lib/types";
|
||||
import {
|
||||
createAdHocDeliveryNote,
|
||||
type AdHocDeliveryNoteInput,
|
||||
type AdHocDeliveryNoteResult,
|
||||
} from "@/lib/server/delivery-notes-service";
|
||||
|
||||
/**
|
||||
* Injectable dependencies for `checkoutSale`.
|
||||
*
|
||||
* `createDeliveryNote` exists so the delivery-note path can be tested
|
||||
* without a live PocketBase: `createAdHocDeliveryNote` opens its own
|
||||
* admin client internally, so a stubbed `PosPb` can't intercept it.
|
||||
* Production passes nothing and gets the real service.
|
||||
*/
|
||||
export type PosCheckoutDeps = {
|
||||
createDeliveryNote?: (input: AdHocDeliveryNoteInput) => Promise<AdHocDeliveryNoteResult>;
|
||||
};
|
||||
|
||||
type CreateDeliveryNoteFn = (input: AdHocDeliveryNoteInput) => Promise<AdHocDeliveryNoteResult>;
|
||||
|
||||
/**
|
||||
* PB client type kept loose so both the real `PocketBase` instance
|
||||
|
|
@ -205,6 +224,25 @@ export type PosCheckoutResult =
|
|||
stockErrors: Array<{ stock_item_id: string; error: string }>;
|
||||
/** True when this was a replay of an already-recorded `client_ref`. */
|
||||
duplicate: boolean;
|
||||
/**
|
||||
* The system delivery note raised for this sale. Non-null ONLY
|
||||
* for `payment_method = "account"` sales that had a customer
|
||||
* picked — cash / card / EFT get the 80mm thermal slip instead.
|
||||
*
|
||||
* The till prints `dnPdfPath` on an A4 office printer and hands
|
||||
* the client two copies. The 80mm thermal delivery-note slip is
|
||||
* no longer produced for account sales.
|
||||
*/
|
||||
deliveryNote: PosSaleDeliveryNote | null;
|
||||
/**
|
||||
* Set when an account sale banked but its delivery note could
|
||||
* not be created. The sale is NOT rolled back — the money and
|
||||
* the stock movement are real and correct. The till surfaces
|
||||
* this so the operator can raise the DN by hand from the CRM
|
||||
* rather than discovering a missing delivery note at invoicing
|
||||
* time. Absent on every other tender type.
|
||||
*/
|
||||
deliveryNoteError?: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
|
|
@ -212,6 +250,19 @@ export type PosCheckoutResult =
|
|||
message: string;
|
||||
};
|
||||
|
||||
/** The delivery note an account sale is linked to, as the till sees it. */
|
||||
export type PosSaleDeliveryNote = {
|
||||
id: string;
|
||||
delivery_note_number: string;
|
||||
/**
|
||||
* Same-origin path the till opens in an iframe to print. Goes
|
||||
* through `assertPosAuth` — the till's bearer key is the only
|
||||
* credential it holds, so this must NOT point at the CRM's
|
||||
* unauthenticated `/api/invoicing/delivery-note-pdf`.
|
||||
*/
|
||||
pdfPath: string;
|
||||
};
|
||||
|
||||
/** PB filter strings are built by hand here, so quote-escape anything user-supplied. */
|
||||
function escapePbValue(value: string): string {
|
||||
return value.replace(/'/g, "\\'");
|
||||
|
|
@ -237,6 +288,130 @@ async function findSaleByClientRef(
|
|||
return { sale, items: itemsPage.items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (or recovers) the system delivery note for an `account`
|
||||
* sale. No-op for every other tender — cash / card / EFT keep the
|
||||
* 80mm thermal slip and never touch `delivery_notes`.
|
||||
*
|
||||
* Runs on BOTH the fresh-sale path and the idempotent-replay path.
|
||||
* That second call is the whole reason this lives inside
|
||||
* `checkoutSale` rather than being a second call the till makes after
|
||||
* the fact: the till's offline queue (`flushQueue` in
|
||||
* `public/pos/pos-app.js`) re-posts `/checkout` and nothing else, so
|
||||
* a client-side follow-up call would be silently dropped for every
|
||||
* sale taken while the network was down. Folding it in here means the
|
||||
* delivery note is created on whichever attempt actually reaches the
|
||||
* server.
|
||||
*
|
||||
* `pos_sales.delivery_note` is the anchor that makes it exactly-once:
|
||||
* if it's already set we hand back the existing DN rather than making
|
||||
* a second one. That also self-heals — a sale whose first DN creation
|
||||
* failed leaves the field blank, so the retry tries again.
|
||||
*
|
||||
* Failures are deliberately non-fatal. By this point the sale is
|
||||
* banked and stock has moved; a missing delivery note is a paperwork
|
||||
* gap the operator can close by hand, whereas failing the whole
|
||||
* checkout would leave the drawer and the stock ledger disagreeing
|
||||
* with reality. The error is returned so the till can shout about it.
|
||||
*/
|
||||
async function ensureSaleDeliveryNote(
|
||||
pb: PosPb,
|
||||
sale: PosSale,
|
||||
items: PosSaleItem[],
|
||||
createDeliveryNote: CreateDeliveryNoteFn,
|
||||
): Promise<{ deliveryNote: PosSaleDeliveryNote | null; deliveryNoteError?: string }> {
|
||||
if (sale.payment_method !== "account") {
|
||||
return { deliveryNote: null };
|
||||
}
|
||||
if (!sale.customer) {
|
||||
// The till already blocks an account sale with no customer picked
|
||||
// (`validateConfirm`), so this is a belt-and-braces guard for a
|
||||
// hand-rolled / replayed request. Surface it rather than silently
|
||||
// producing an account sale with no paperwork.
|
||||
return {
|
||||
deliveryNote: null,
|
||||
deliveryNoteError:
|
||||
"Account sale with no customer — no delivery note was created. Raise one by hand from the CRM.",
|
||||
};
|
||||
}
|
||||
|
||||
// Already linked (fresh read or a replay) — hand back what exists.
|
||||
if (sale.delivery_note) {
|
||||
try {
|
||||
const dn = await pb
|
||||
.collection("delivery_notes")
|
||||
.getOne<{ id: string; delivery_note_number: string }>(sale.delivery_note);
|
||||
return { deliveryNote: toPosSaleDeliveryNote(dn) };
|
||||
} catch {
|
||||
// The relation points at a row that no longer exists. Fall
|
||||
// through and try to create a replacement rather than stranding
|
||||
// the sale with an unreadable link.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createDeliveryNote({
|
||||
// Company is deliberately NOT passed: createAdHocDeliveryNote's
|
||||
// route derives it from `customer.company` so the operator can't
|
||||
// produce a customer/company mismatch. We do the same lookup
|
||||
// here because we call the service directly, not the route.
|
||||
customer: sale.customer,
|
||||
company: await deriveCompanyId(pb, sale.customer),
|
||||
notes: "Raised at the POS on an account sale.",
|
||||
items: items.map((it) => ({
|
||||
description: it.description || "",
|
||||
quantity: Number(it.qty ?? 0),
|
||||
cost_price: Number(it.unit_cost ?? 0),
|
||||
selling_price: Number(it.unit_price ?? 0),
|
||||
sku: it.sku || "",
|
||||
})),
|
||||
createdBy: null,
|
||||
});
|
||||
// Stamp the anchor. If this write fails the DN still exists and is
|
||||
// printable, but a replay would try again — which is why the
|
||||
// unique-index retry in the create service matters.
|
||||
try {
|
||||
await pb.collection("pos_sales").update(sale.id, { delivery_note: created.id });
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[pos.checkout] delivery note created but pos_sales.delivery_note not stamped:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
return { deliveryNote: toPosSaleDeliveryNote(created) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[pos.checkout] delivery note creation failed:", message);
|
||||
return {
|
||||
deliveryNote: null,
|
||||
deliveryNoteError: `Sale completed but the delivery note could not be created (${message}). Raise one by hand from the CRM.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toPosSaleDeliveryNote(dn: { id: string; delivery_note_number: string }): PosSaleDeliveryNote {
|
||||
return {
|
||||
id: dn.id,
|
||||
delivery_note_number: dn.delivery_note_number,
|
||||
pdfPath: `/delivery-note/${encodeURIComponent(dn.delivery_note_number)}/pdf`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's own company relation. Mirrors the derive step in
|
||||
* `POST /api/v1/delivery-notes` so both create paths agree.
|
||||
*/
|
||||
async function deriveCompanyId(pb: PosPb, customerId: string): Promise<string | null> {
|
||||
try {
|
||||
const row = await pb.collection("customers").getOne<{ company?: string }>(customerId);
|
||||
return row.company && row.company.length > 0 ? row.company : null;
|
||||
} catch {
|
||||
// An unreadable customer fails later in the create service with a
|
||||
// clearer message. Don't pre-empt it here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a POS sale: validates stock availability, creates the
|
||||
* `pos_sales`/`pos_sale_items` rows, then for each line writes a
|
||||
|
|
@ -269,7 +444,9 @@ async function findSaleByClientRef(
|
|||
export async function checkoutSale(
|
||||
pb: PosPb,
|
||||
input: PosCheckoutInput,
|
||||
deps: PosCheckoutDeps = {},
|
||||
): Promise<PosCheckoutResult> {
|
||||
const createDeliveryNote = deps.createDeliveryNote ?? createAdHocDeliveryNote;
|
||||
if (!Array.isArray(input.items) || input.items.length === 0) {
|
||||
return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
|
||||
}
|
||||
|
|
@ -290,7 +467,25 @@ export async function checkoutSale(
|
|||
try {
|
||||
const existing = await findSaleByClientRef(pb, clientRef);
|
||||
if (existing) {
|
||||
return { ok: true, sale: existing.sale, items: existing.items, stockErrors: [], duplicate: true };
|
||||
// The delivery note is recovered here, not re-created: an
|
||||
// offline sale replayed on reconnect must return the SAME DN
|
||||
// the first successful attempt produced, or the client gets
|
||||
// two documents for one delivery.
|
||||
const { deliveryNote, deliveryNoteError } = await ensureSaleDeliveryNote(
|
||||
pb,
|
||||
existing.sale,
|
||||
existing.items,
|
||||
createDeliveryNote,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
sale: existing.sale,
|
||||
items: existing.items,
|
||||
stockErrors: [],
|
||||
duplicate: true,
|
||||
deliveryNote,
|
||||
...(deliveryNoteError ? { deliveryNoteError } : {}),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
@ -420,7 +615,22 @@ export async function checkoutSale(
|
|||
}
|
||||
}
|
||||
|
||||
return { ok: true, sale, items, stockErrors, duplicate: false };
|
||||
const { deliveryNote, deliveryNoteError } = await ensureSaleDeliveryNote(
|
||||
pb,
|
||||
sale,
|
||||
items,
|
||||
createDeliveryNote,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sale,
|
||||
items,
|
||||
stockErrors,
|
||||
duplicate: false,
|
||||
deliveryNote,
|
||||
...(deliveryNoteError ? { deliveryNoteError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── listSalesInRange ───────────────────────────────────────────────
|
||||
|
|
@ -441,6 +651,18 @@ export async function listSalesInRange(
|
|||
const salesPage = await pb.collection("pos_sales").getList<PosSale>(1, 500, {
|
||||
filter: `created >= '${escapePbValue(range.from)}' && created <= '${escapePbValue(range.to)}'`,
|
||||
sort: "-created",
|
||||
// Expand the relations `history.html` needs to rebuild the delivery
|
||||
// note card on a reprint:
|
||||
// customer — the "Attention:" name
|
||||
// customer.company — the company NAME. `customer.company` is
|
||||
// itself a relation, so without the nested expand the card
|
||||
// printed a raw PB id ("rpqogiu03d97lcv") where the company
|
||||
// name belongs. PB's syntax for a nested expand prefixes
|
||||
// every level.
|
||||
// delivery_note — the DN NUMBER, not just the relation id,
|
||||
// because that is what the till's PDF route is keyed on.
|
||||
// PB resolves all of these in one extra query, not one per row.
|
||||
expand: "customer,customer.company,delivery_note",
|
||||
});
|
||||
const sales = salesPage.items;
|
||||
if (sales.length === 0) return { sales: [], items: [] };
|
||||
|
|
@ -458,6 +680,11 @@ export type PosCustomerResult = { id: string; name: string; company: string };
|
|||
* Customer picker for account sales. Lives behind the POS API rather
|
||||
* than being a direct PB query from the till — the whole point of this
|
||||
* app's rewrite is that no PocketBase credentials reach the client.
|
||||
*
|
||||
* `company` is the company's display name. It is NOT sent onward as
|
||||
* the DN's company — `createAdHocDeliveryNote` derives that from the
|
||||
* customer's own `company` relation server-side, which is the only
|
||||
* value that can't drift. The till is a display surface here.
|
||||
*/
|
||||
export async function searchCustomersForPos(
|
||||
pb: PosPb,
|
||||
|
|
|
|||
|
|
@ -391,9 +391,11 @@ export async function generateQuotePdf(
|
|||
: null;
|
||||
|
||||
// ── 5. Render ────────────────────────────────────────────
|
||||
// Letterhead + logo (SVG as a data URI). @react-pdf v4.5.1
|
||||
// corrupts any raster (PNG/JPEG) byte ≥ 0x80, so we ship SVG
|
||||
// instead.
|
||||
// Letterhead + logo. The logo ships as an SVG data URI because
|
||||
// `public/letterhead/*.svg` is an SVG source file — the raster
|
||||
// corruption this comment used to blame on @react-pdf was
|
||||
// actually the old `pdf().toString()` render pipeline. See the
|
||||
// note at the top of `lib/pdf/render.tsx`.
|
||||
const letterhead = await fetchLetterhead(letterheadCode);
|
||||
const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode);
|
||||
|
||||
|
|
|
|||
|
|
@ -1703,6 +1703,36 @@ export interface PosSale extends BaseRecord {
|
|||
* this returns the original sale instead of decrementing stock twice.
|
||||
*/
|
||||
client_ref: string;
|
||||
/**
|
||||
* The system `delivery_notes` record raised for this sale, set only
|
||||
* on `payment_method = "account"` sales that had a customer picked.
|
||||
*
|
||||
* This is the idempotency anchor for the delivery note. The till's
|
||||
* offline queue re-posts `/checkout` with the same `client_ref` when
|
||||
* the network drops, and `checkoutSale` replays the original sale
|
||||
* untouched — reading this field first, so one physical sale can
|
||||
* never produce two delivery notes. Blank means "no DN yet", which
|
||||
* is also the retry signal if the first creation attempt failed.
|
||||
*
|
||||
* Cash / card / EFT sales leave it blank forever — they get the
|
||||
* 80mm thermal slip, not a delivery note.
|
||||
*/
|
||||
delivery_note?: string;
|
||||
/**
|
||||
* Present on rows from `GET /api/v1/pos/sales` (history.html and
|
||||
* cashup.html), which expands the customer and the linked delivery
|
||||
* note so the till can rebuild the DN card on a reprint. Absent
|
||||
* elsewhere.
|
||||
*/
|
||||
expand?: {
|
||||
customer?: {
|
||||
id?: string;
|
||||
customer?: string;
|
||||
company?: string;
|
||||
expand?: { company?: { id?: string; company?: string } };
|
||||
};
|
||||
delivery_note?: { id?: string; delivery_note_number?: string };
|
||||
};
|
||||
}
|
||||
|
||||
/** How the operator entered the discount on the till. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue