refactor(pos): make buildEscPos/buildEscPosDelivery (Bluetooth) block-driven

Also fixes a live bug found while refactoring: buildEscPos returned a
Uint8Array, but its caller in printThermalBluetooth() ran the result
through TextEncoder.encode() (which expects a string) before writing
to the Bluetooth characteristic — encoding a Uint8Array's default
toString() ("27,64,27,...") instead of the actual bytes. Regular
(non-account) sale slips printed over Bluetooth have been sending
garbled data. Both builders now return plain strings, matching
buildEscPosDelivery's already-correct pattern.

Also preserves a pre-existing divergence rather than silently
resolving it: Bluetooth's delivery note prints letterhead business
info (name/address/phone/VAT), the network XML version never has.
Kept as-is per transport (folded into Bluetooth's header_title block)
rather than unilaterally adding or removing content — worth an
explicit decision later on which behavior is correct.
This commit is contained in:
Dave 2026-09-04 13:35:19 +02:00
parent d47394723b
commit f07d70b976

View file

@ -588,11 +588,12 @@ async function printThermalBluetooth(){
status.textContent = 'Printing…';
// 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 : true;
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBt);
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices)
: buildEscPos(sale);
? buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'delivery'))
: buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
const CHUNK = 80;
const enc = new TextEncoder();
for(let i=0;i<text.length;i+=CHUNK){
@ -610,62 +611,85 @@ async function printThermalBluetooth(){
}
}
function buildEscPos(s){
function saleEscPosRenderers(s){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const cmds = [];
cmds.push(ESC+'@');
cmds.push(ESC+'a'+E(1));
cmds.push(ESC+'!'+E(0x30));
cmds.push('WFSALES POS\n');
cmds.push(ESC+'!'+E(0x00));
if(s.letterhead?.name) cmds.push(s.letterhead.name+'\n');
cmds.push(ESC+'a'+E(0));
if(s.letterhead){
if(s.letterhead.address) cmds.push(s.letterhead.address.split('\n')[0]+'\n');
if(s.letterhead.phone) cmds.push('Tel: '+s.letterhead.phone+'\n');
if(s.letterhead.vat_number) cmds.push('VAT: '+s.letterhead.vat_number+'\n');
}
cmds.push('--------------------------------\n');
const d = new Date(s.createdAt);
cmds.push('Date: ' + d.toLocaleString('en-ZA')+'\n');
cmds.push('Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n');
if(s.customer) cmds.push('Customer: ' + s.customer.name + '\n');
cmds.push('--------------------------------\n');
for(const it of s.items){
cmds.push((it.description||it.sku) + '\n');
const line = ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}`;
cmds.push(line + '\n');
}
cmds.push('--------------------------------\n');
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`);
}
cmds.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`);
cmds.push(`VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`);
cmds.push(ESC+'!'+E(0x08));
cmds.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n`);
cmds.push(ESC+'!'+E(0x00));
cmds.push('Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n');
if(s.payment==='cash') cmds.push(`Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`);
cmds.push('--------------------------------\n');
if(s.letterhead?.footer_text) cmds.push(s.letterhead.footer_text+'\n');
cmds.push('\n\n');
cmds.push(GS+'V'+E(0x41)+E(0x10));
const text = cmds.join('');
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
return bytes;
return {
header_title: (block) => ESC+'a'+E(1) + ESC+'!'+E(0x30) + (block.text||'WFSALES POS') + '\n' + ESC+'!'+E(0x00),
// Web Bluetooth GATT printing has no raster-image support in this
// app — this block intentionally prints nothing on this transport.
logo: () => '',
business_info: () => {
let out = ESC+'a'+E(0);
if(s.letterhead?.name) out += s.letterhead.name+'\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;
},
date_slip: () => {
const d = new Date(s.createdAt);
return '--------------------------------\n'
+ 'Date: ' + d.toLocaleString('en-ZA')+'\n'
+ 'Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n';
},
customer: () => s.customer ? ('Customer: ' + s.customer.name + '\n') : '',
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku) + '\n';
out += ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
out += `Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`;
out += `VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`;
out += `Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`;
out += `Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`;
}
out += `Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`;
out += `VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`;
out += ESC+'!'+E(0x08) + `TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
return out;
},
payment: () => {
let out = 'Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n';
if(s.payment==='cash') out += `Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`;
return out;
},
footer: (block) => {
const text = block.text || s.letterhead?.footer_text;
let out = '--------------------------------\n';
if(text) out += text+'\n';
return out;
},
};
}
// `blocks` is the resolved block list for 'sale'. Returns a plain
// string (matching buildEscPosDelivery) — 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
// a string, producing garbled output. Fixed as part of this refactor.)
function buildEscPos(s, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const renderers = saleEscPosRenderers(s);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
}
async function printDelivery(){
@ -706,9 +730,10 @@ async function printDelivery(){
btn.disabled = false;
return;
}
const letterheadIdBtDn = sale.letterhead && sale.letterhead.id;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : true;
const text = buildEscPosDelivery(sale, showPrices);
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{
@ -725,47 +750,66 @@ 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){
// Note: this transport's delivery note has always printed letterhead
// business info (name/address/phone/VAT) unconditionally after the
// title — the network XML version (EposPrint.buildEposDeliveryXml)
// never has. That's a pre-existing divergence between the two
// transports, not something introduced here; this refactor preserves
// each transport's current exact output rather than unilaterally
// picking one, so the business info stays baked into this
// transport's header_title block specifically.
function deliveryEscPosRenderers(s, showPrices){
return {
header_title: (block) => {
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const title = block.text || 'WFSALES POS';
let out = ESC+'a'+E(1) + ESC+'!'+E(0x10) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
if(s.letterhead && s.letterhead.name) out += s.letterhead.name + '\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;
},
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 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('');
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(){