From f07d70b97673a618ea2df84825a171672b90d8fa Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 13:35:19 +0200 Subject: [PATCH] refactor(pos): make buildEscPos/buildEscPosDelivery (Bluetooth) block-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/pos/receipt.html | 232 ++++++++++++++++++++++++---------------- 1 file changed, 138 insertions(+), 94 deletions(-) diff --git a/public/pos/receipt.html b/public/pos/receipt.html index 0f9a38a..317b718 100644 --- a/public/pos/receipt.html +++ b/public/pos/receipt.html @@ -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 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 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 { + 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(){