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:
parent
d47394723b
commit
f07d70b976
1 changed files with 138 additions and 94 deletions
|
|
@ -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(){
|
||||
|
|
|
|||
Loading…
Reference in a new issue