feat(pos): 'Print slip' on account sales auto-routes to delivery note (network + BT)

This commit is contained in:
Dave 2026-09-04 10:40:48 +02:00
parent 79cc7d1f0a
commit 5c9f30dbd1
2 changed files with 76 additions and 39 deletions

View file

@ -525,10 +525,26 @@ async function printThermalNetwork(settings){
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
const xml = window.EposPrint.buildEposXml(sale, logo);
// Account sales print a delivery note (no payment line, has
// received-by / driver lines) — that's the document the driver
// 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') {
// Logos don't always render cleanly on the TM-T20II at small
// sizes; the delivery note skips the logo so the address + items
// get the most space. If you want the logo back, set `logo`
// here and pass it to buildEposDeliveryXml the same way
// buildEposXml takes it.
xml = window.EposPrint.buildEposDeliveryXml(sale);
successLabel = 'Delivery note printed · ';
} else {
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
xml = window.EposPrint.buildEposXml(sale, logo);
successLabel = 'Printed · ';
}
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = 'Printed · ' + settings.ip;
status.textContent = successLabel + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
@ -568,14 +584,21 @@ async function printThermalBluetooth(){
}
if(!btChar) throw new Error('No writable characteristic on printer.');
status.textContent = 'Printing…';
const payload = buildEscPos(sale);
// Account sales print a delivery note (received-by / driver lines,
// no payment line). Everything else prints the standard sale slip.
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : true;
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices)
: buildEscPos(sale);
const CHUNK = 80;
for(let i=0;i<payload.length;i+=CHUNK){
const slice = payload.slice(i, i+CHUNK);
await btChar.writeValueWithoutResponse(slice);
const enc = new TextEncoder();
for(let i=0;i<text.length;i+=CHUNK){
const slice = text.slice(i, i+CHUNK);
await btChar.writeValueWithoutResponse(enc.encode(slice));
await new Promise(r=>setTimeout(r,30));
}
status.textContent = 'Printed · ' + (btDevice.name||'printer');
status.textContent = (sale.payment === 'account' ? 'Delivery note printed · ' : 'Printed · ') + (btDevice.name||'printer');
}catch(e){
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
else status.textContent = 'Print failed: ' + e.message;
@ -679,38 +702,9 @@ async function printDelivery(){
btn.disabled = false;
return;
}
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
// Mirror the on-screen "Include prices" toggle so the printed slip
// matches what the operator just checked.
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : true;
const lines = [];
lines.push(ESC+'@');
lines.push(ESC+'a'+E(1));
lines.push(ESC+'!'+E(0x10));
lines.push('WFSALES POS\n');
lines.push(ESC+'!'+E(0x00));
lines.push('Delivery Note\n');
lines.push(ESC+'a'+E(0));
if(sale.customer?.company) lines.push('Company: ' + sale.customer.company + '\n');
lines.push('Customer: ' + (sale.customer?.name||'—') + '\n');
const d = new Date(sale.createdAt);
lines.push('Date: ' + d.toLocaleDateString('en-ZA')+'\n');
lines.push('--------------------------------\n');
for(const it of sale.items){
lines.push((it.description||it.sku)+'\n');
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
lines.push(showPrices
? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n`
: `${qtyLine}\n`);
}
lines.push('--------------------------------\n');
if(showPrices) lines.push('TOTAL: ' + fmt(sale.total) + '\n');
lines.push('Received by: __________ Driver: __________\n');
lines.push('\n\n');
lines.push(String.fromCharCode(0x1D)+'V'+String.fromCharCode(0x41)+String.fromCharCode(0x10));
const text = lines.join('');
const text = buildEscPosDelivery(sale, showPrices);
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
try{
@ -727,6 +721,49 @@ async function printDelivery(){
}
}
// Builds the ESC/POS byte stream for a delivery note. Used by
// `printDelivery` (the explicit button) and by `printThermalBluetooth`
// when the till prints an account sale via the "Print slip" button.
// Mirrors the ePOS-Print XML in `EposPrint.buildEposDeliveryXml` —
// when one changes, change the other so the on-paper output stays
// in sync.
function buildEscPosDelivery(s, showPrices){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const lines = [];
lines.push(ESC+'@');
lines.push(ESC+'a'+E(1));
lines.push(ESC+'!'+E(0x10));
lines.push('WFSALES POS\n');
lines.push(ESC+'!'+E(0x00));
lines.push('Delivery Note\n');
lines.push(ESC+'a'+E(0));
if(s.letterhead && s.letterhead.name) lines.push(s.letterhead.name + '\n');
if(s.letterhead){
if(s.letterhead.address) lines.push(s.letterhead.address.split('\n')[0]+'\n');
if(s.letterhead.phone) lines.push('Tel: '+s.letterhead.phone+'\n');
if(s.letterhead.vat_number) lines.push('VAT: '+s.letterhead.vat_number+'\n');
}
if(s.customer && s.customer.company) lines.push('Company: ' + s.customer.company + '\n');
lines.push('Customer: ' + (s.customer && s.customer.name || '—') + '\n');
const d = new Date(s.createdAt);
lines.push('Date: ' + d.toLocaleDateString('en-ZA')+'\n');
lines.push('--------------------------------\n');
for(const it of s.items){
lines.push((it.description||it.sku)+'\n');
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
lines.push(showPrices
? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n`
: `${qtyLine}\n`);
}
lines.push('--------------------------------\n');
if(showPrices) lines.push('TOTAL: ' + fmt(s.total) + '\n');
lines.push('Received by: __________ Driver: __________\n');
lines.push('\n\n');
lines.push(E(0x1D)+'V'+E(0x41)+E(0x10));
return lines.join('');
}
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';

View file

@ -1,5 +1,5 @@
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v19";
const CACHE = "pos-v20";
const APP_SHELL = [
'./',
'./index.html',