refactor(pos): make buildEposXml block-driven

This commit is contained in:
Dave 2026-09-04 13:27:13 +02:00
parent c56211d35e
commit 81197ad641
2 changed files with 82 additions and 44 deletions

View file

@ -72,52 +72,87 @@ window.EposPrint = (function () {
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
// Mirrors buildEscPos() in receipt.html — same fields, same discount
// math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes.
function buildEposXml(s, logo) {
const parts = [];
if (logo) parts.push(`<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>`);
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
if (s.letterhead && s.letterhead.name) {
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
}
const addrLines = [];
if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0]));
if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone));
if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number));
if (addrLines.length) parts.push(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`);
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`;
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
// Per-block XML fragment renderers for the sale slip. Each function
// returns the exact XML this block contributes; `block` is the
// resolved block ({id, enabled, text}) so header_title/footer can
// read their custom text. Dividers are owned by whichever block
// came after them in the original fixed sequence — reordering a
// block takes its leading divider with it.
function saleBlockRenderers(s, logo) {
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;
parts.push(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`);
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)}&#10;</text>`);
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
return {
header_title: function (block) {
const title = block.text || 'WFSALES POS';
return '<feed line="1"/><text align="center" width="2" height="2">' + xmlEscape(title) + '&#10;</text>';
},
logo: function () {
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
},
business_info: function () {
const parts = [];
if (s.letterhead && s.letterhead.name) parts.push(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
const addrLines = [];
if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0]));
if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone));
if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number));
if (addrLines.length) parts.push(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
return parts.join('');
},
date_slip: function () {
const d = new Date(s.createdAt);
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`;
},
customer: function () {
return s.customer ? `<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>` : '';
},
items: function () {
const itemLines = s.items.map(function (it) {
const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`;
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
return `<text align="left">--------------------------------&#10;${itemLines}</text>`;
},
totals: function () {
const parts = ['<text align="left">--------------------------------&#10;</text>'];
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;
parts.push(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
return parts.join('');
},
payment: function () {
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
let out = `<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`;
if (s.payment === 'cash') out += `<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`;
return out;
},
footer: function (block) {
const text = block.text || (s.letterhead && s.letterhead.footer_text);
let out = '<text align="left">--------------------------------&#10;</text>';
if (text) out += `<text align="center">${xmlEscape(text)}&#10;</text>`;
return out;
},
};
}
// `blocks` is the resolved block list from PosSlipLayout.resolveBlocks('sale', ...)
// (or getBlocksForPrint). Renders each enabled block in order, then
// the fixed closing feed+cut every sale slip ends with.
function buildEposXml(s, logo, blocks) {
const renderers = saleBlockRenderers(s, logo);
const body = blocks
.filter(function (b) { return b.enabled; })
.map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; })
.join('');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
}
async function printViaNetwork(ip, xml) {
@ -284,7 +319,8 @@ window.EposPrint = (function () {
const logo = lh.logo_url
? await imageToRaster(lh.logo_url, 384).catch(() => null)
: null;
const xml = buildEposXml(sale, logo);
const blocks = window.PosSlipLayout.getBlocksForPrint(lh.id, 'sale');
const xml = buildEposXml(sale, logo, blocks);
if (settings.type === 'network') {
if (!settings.ip) {
throw new Error('Set the printer IP address in Printer settings first.');

View file

@ -541,7 +541,9 @@ async function printThermalNetwork(settings){
successLabel = 'Delivery note printed · ';
} else {
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
xml = window.EposPrint.buildEposXml(sale, logo);
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);