refactor(pos): make buildEposCashupXml block-driven, fix broken logo image tag

Also fixes a logo-ordering bug this surfaced: slip-layout.js's sale
and cashup defaults put header_title before logo, which (once a real
logo renders instead of the always-empty placeholder used in earlier
regression tests) reversed the original image-then-title print order.
Reordered both to logo-first and added the missing logo block to
cashup's default catalog, since cashup's original code already
attempted (brokenly) to print one.
This commit is contained in:
Dave 2026-09-04 13:31:13 +02:00
parent 317b79ed29
commit d47394723b
3 changed files with 83 additions and 63 deletions

View file

@ -444,7 +444,11 @@ async function printCashup(){
denoms,
};
const settings = window.EposPrint.getSettings();
const xml = window.EposPrint.buildEposCashupXml(letterhead, report);
const cashupBlocks = window.PosSlipLayout.getBlocksForPrint(letterhead.id, 'cashup');
const cashupLogo = letterhead.logo_url
? await window.EposPrint.imageToRaster(letterhead.logo_url, 384).catch(() => null)
: null;
const xml = window.EposPrint.buildEposCashupXml(letterhead, report, cashupBlocks, cashupLogo);
if(settings.type === 'network'){
if(!settings.ip){
alert('Set the printer IP address in Printer settings first.');

View file

@ -236,69 +236,84 @@ window.EposPrint = (function () {
// variance: number,
// denoms: [{ label: "R200", value: 200, count: 2 }, ...],
// }
// `letterhead` is the same shape as buildEposXml/Xml takes.
function buildEposCashupXml(letterhead, report) {
function cashupBlockRenderers(lh, r, logo) {
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>`
+ '<text align="center" width="1" height="1">CASH-UP REPORT&#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 (lh.display_name) parts.push(`<text align="center">${xmlEscape(lh.display_name)}&#10;</text>`);
if (lh.address) parts.push(`<text align="center">${xmlEscape(lh.address.split('\n')[0])}&#10;</text>`);
if (lh.phone) parts.push(`<text align="center">Tel: ${xmlEscape(lh.phone)}&#10;</text>`);
if (lh.vat_number) parts.push(`<text align="center">VAT: ${xmlEscape(lh.vat_number)}&#10;</text>`);
return parts.join('');
},
shift_date: function () {
let out = '<text align="left">--------------------------------&#10;</text>';
if (r.shiftLabel || r.dateLabel) out += `<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')}&#10;</text>`;
if (r.counts) out += `<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account}&#10;</text>`;
return out;
},
payment_breakdown: function () {
const lines = [
`Cash ${pad((r.counts && r.counts.cash) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.cash) || 0)}`,
`Card ${pad((r.counts && r.counts.card) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.card) || 0)}`,
`EFT ${pad((r.counts && r.counts.eft) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.eft) || 0)}`,
`Acct ${pad((r.counts && r.counts.account) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.account) || 0)}`,
];
return '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">PAYMENT METHOD BREAKDOWN&#10;</text>'
+ `<text align="left">${lines.join('&#10;')}&#10;</text>`;
},
grand_total: function () {
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)}&#10;</text>`;
},
cash_reconciliation: function () {
return '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">CASH RECONCILIATION&#10;</text>'
+ `<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)}&#10;</text>`
+ `<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)}&#10;</text>`
+ `<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)}&#10;</text>`;
},
denominations: function () {
if (!Array.isArray(r.denoms) || !r.denoms.length) return '';
const dlines = r.denoms
.filter(function (d) { return (d.count || 0) > 0; })
.map(function (d) { return `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`; });
if (!dlines.length) return '';
return '<text align="left">--------------------------------&#10;</text>'
+ '<text align="left">DENOMINATIONS COUNTED&#10;</text>'
+ `<text align="left">${dlines.join('&#10;')}&#10;</text>`;
},
footer: function (block) {
const text = block.text || lh.footer_text;
if (!text) return '';
return '<text align="left">--------------------------------&#10;</text>'
+ `<text align="center">${xmlEscape(text)}&#10;</text>`;
},
};
}
// `blocks` is the resolved block list for 'cashup'. `logo` is a
// pre-rasterized {width,height,base64} (see imageToRaster) or null —
// rasterizing is async, so the caller does it before calling this.
function buildEposCashupXml(letterhead, report, blocks, logo) {
const lh = letterhead || {};
const r = report || {};
const parts = [];
if (lh.logo_url) {
parts.push(`<image width="384" height="auto" color="color_1" mode="mono" align="center">${(typeof window !== 'undefined' ? '' : '')}</image>`);
}
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
parts.push('<text align="center" width="1" height="1">CASH-UP REPORT&#10;</text>');
if (lh.display_name) {
parts.push(`<text align="center">${xmlEscape(lh.display_name)}&#10;</text>`);
}
if (lh.address) {
parts.push(`<text align="center">${xmlEscape(lh.address.split('\n')[0])}&#10;</text>`);
}
if (lh.phone) {
parts.push(`<text align="center">Tel: ${xmlEscape(lh.phone)}&#10;</text>`);
}
if (lh.vat_number) {
parts.push(`<text align="center">VAT: ${xmlEscape(lh.vat_number)}&#10;</text>`);
}
parts.push('<text align="left">--------------------------------&#10;</text>');
if (r.shiftLabel || r.dateLabel) {
parts.push(`<text align="left">Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')}&#10;</text>`);
}
if (r.counts) {
parts.push(`<text align="left">Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account}&#10;</text>`);
}
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">PAYMENT METHOD BREAKDOWN&#10;</text>');
const lines = [
`Cash ${pad((r.counts && r.counts.cash) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.cash) || 0)}`,
`Card ${pad((r.counts && r.counts.card) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.card) || 0)}`,
`EFT ${pad((r.counts && r.counts.eft) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.eft) || 0)}`,
`Acct ${pad((r.counts && r.counts.account) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.account) || 0)}`,
];
parts.push(`<text align="left">${lines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push(`<text align="left" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">CASH RECONCILIATION&#10;</text>');
parts.push(`<text align="left">Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)}&#10;</text>`);
parts.push(`<text align="left">Cash counted: R ${fmtMoney(r.cashCounted || 0)}&#10;</text>`);
parts.push(`<text align="left" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)}&#10;</text>`);
if (Array.isArray(r.denoms) && r.denoms.length) {
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push('<text align="left">DENOMINATIONS COUNTED&#10;</text>');
const dlines = r.denoms
.filter(d => (d.count || 0) > 0)
.map(d => `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`);
if (dlines.length) {
parts.push(`<text align="left">${dlines.join('&#10;')}&#10;</text>`);
}
}
if (lh.footer_text) {
parts.push('<text align="left">--------------------------------&#10;</text>');
parts.push(`<text align="center">${xmlEscape(lh.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>`;
const renderers = cashupBlockRenderers(lh, r, logo || null);
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>`;
}
// Small helper for padded numbers in the cashup slip.

View file

@ -44,8 +44,8 @@ window.PosSlipLayout = (function () {
// consistency with the other two slip types.
const DEFAULTS = {
sale: [
{ id: 'header_title', enabled: true, text: null },
{ id: 'logo', enabled: true },
{ id: 'header_title', enabled: true, text: null },
{ id: 'business_info', enabled: true },
{ id: 'date_slip', enabled: true },
{ id: 'customer', enabled: true },
@ -63,6 +63,7 @@ window.PosSlipLayout = (function () {
{ id: 'signature', enabled: true },
],
cashup: [
{ id: 'logo', enabled: true },
{ id: 'header_title', enabled: true, text: null },
{ id: 'business_info', enabled: true },
{ id: 'shift_date', enabled: true },