Logo — three separate faults, all of which had to be fixed for the letterhead logo to print at all: 1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it printed the app's bundled mark (the solid black W block) and never looked at the letterhead at all. 2. The paths that DID use the letterhead (printTest, cashup) passed PocketBase's fully-qualified cross-origin URL into imageToRaster(), which draws to a <canvas> — getImageData() taints and throws on a cross-origin image, so the .catch(() => null) silently dropped the logo. pos-letterhead-service now returns a same-origin /api/v1/files/... URL (per AGENTS.md's file-URL rule). 3. letterhead_settings wasn't whitelisted in that file proxy, so the same-origin URL would have 403'd. Verified against the live WESTSCOTT letterhead: its SVG rasterizes to 384x137 at ~15% ink coverage (line art), no tainted canvas. Also fixes the company name never printing: every builder read `letterhead.name`, but the POS letterhead record exposes `display_name` — so the name line silently rendered empty on every slip, on screen and on paper. Delivery note now matches the sale slip, per request: gains the logo and business_info blocks and the same 2x-size title, instead of being a stripped-down variant. This also reconciles the transport divergence flagged earlier (Bluetooth baked business info into its header; the network builder omitted it entirely) — it is now one reorderable business_info block on both. resolveBlocks() now slots a newly-shipped block in at its intended position rather than appending it to the end. Without this, any letterhead with an already-saved layout would have printed the new logo/business_info blocks BELOW the signature line.
425 lines
22 KiB
JavaScript
425 lines
22 KiB
JavaScript
// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet
|
|
// printers. Runs alongside the existing Web Bluetooth path in
|
|
// receipt.html — the till picks a transport in Printer settings.
|
|
//
|
|
// The printer's ePOS-Print endpoint is served over HTTPS with a
|
|
// self-signed cert and open CORS (`Access-Control-Allow-Origin: *`),
|
|
// confirmed by direct testing against the shop's TM-T20II. Each till
|
|
// device must visit `https://<printer-ip>` once in its browser and
|
|
// accept the certificate warning before fetch() calls here succeed.
|
|
window.EposPrint = (function () {
|
|
'use strict';
|
|
|
|
const SETTINGS_KEY = 'pos.printerSettings';
|
|
|
|
function getSettings() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' };
|
|
} catch (e) {
|
|
return { type: 'bluetooth', ip: '' };
|
|
}
|
|
}
|
|
|
|
function setSettings(settings) {
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
}
|
|
|
|
// Rasterize a same-origin image into the packed 1bpp bitmap the
|
|
// ePOS-Print <image> tag expects. targetWidth must be a multiple of
|
|
// 8; 384 is the width verified against the shop's TM-T20II.
|
|
function imageToRaster(url, targetWidth) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const targetHeight = Math.round(img.height * targetWidth / img.width);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillRect(0, 0, targetWidth, targetHeight);
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
let imageData;
|
|
try {
|
|
imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
|
|
} catch (e) {
|
|
reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message));
|
|
return;
|
|
}
|
|
const data = imageData.data;
|
|
const rowBytes = Math.ceil(targetWidth / 8);
|
|
const packed = new Uint8Array(rowBytes * targetHeight);
|
|
for (let y = 0; y < targetHeight; y++) {
|
|
for (let x = 0; x < targetWidth; x++) {
|
|
const i = (y * targetWidth + x) * 4;
|
|
const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
|
|
const alpha = data[i + 3];
|
|
if (alpha > 128 && luminance < 128) {
|
|
packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8);
|
|
}
|
|
}
|
|
}
|
|
let binary = '';
|
|
for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]);
|
|
resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) });
|
|
};
|
|
img.onerror = () => reject(new Error('Could not load logo image: ' + url));
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
// Rasterize the ACTIVE LETTERHEAD's logo for printing — not the
|
|
// app's bundled logo. `letterhead.logo_url` is served same-origin
|
|
// through /api/v1/files/ (see pos-letterhead-service.ts); a raw
|
|
// PocketBase URL would taint the canvas in imageToRaster() and
|
|
// silently print no logo at all.
|
|
//
|
|
// Returns null when the letterhead has no logo uploaded — we
|
|
// deliberately do NOT fall back to the bundled WFDASH logo, since
|
|
// printing one company's mark on another company's slip is worse
|
|
// than printing none.
|
|
async function letterheadLogoRaster(letterhead, width) {
|
|
const url = letterhead && letterhead.logo_url;
|
|
if (!url) return null;
|
|
return imageToRaster(url, width || 384).catch(function () { return null; });
|
|
}
|
|
|
|
function xmlEscape(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
|
}
|
|
|
|
// 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.
|
|
// ePOS-Print's <text> style attributes (width, height, em, ul,
|
|
// reverse) are STATEFUL — the printer keeps whatever a previous
|
|
// <text> tag set until a later tag explicitly changes it back; they
|
|
// don't reset per-tag the way a browser resets inline styles. Since
|
|
// blocks are user-reorderable/toggleable, no block can assume what
|
|
// state a neighboring block left behind — every plain-style tag
|
|
// here explicitly declares width="1" height="1" em="false" so it's
|
|
// correct regardless of what printed immediately before it.
|
|
const NORMAL = 'width="1" height="1" em="false"';
|
|
|
|
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;
|
|
return {
|
|
header_title: function (block) {
|
|
const title = block.text || 'WFSALES POS';
|
|
return '<feed line="1"/><text align="center" width="2" height="2">' + xmlEscape(title) + ' </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 = [];
|
|
// The POS letterhead record exposes `display_name`, not `name`
|
|
// — reading `.name` printed nothing at all on every slip.
|
|
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
|
|
if (bizName) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(bizName)} </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" ${NORMAL}>${addrLines.join(' ')} </text>`);
|
|
return parts.join('');
|
|
},
|
|
date_slip: function () {
|
|
const d = new Date(s.createdAt);
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} </text>`;
|
|
},
|
|
customer: function () {
|
|
return s.customer ? `<text align="left" ${NORMAL}>Customer: ${xmlEscape(s.customer.name)} </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)} ${xmlEscape(priceLine)} `;
|
|
}).join('');
|
|
return `<text align="left" ${NORMAL}>-------------------------------- ${itemLines}</text>`;
|
|
},
|
|
totals: function () {
|
|
const parts = [`<text align="left" ${NORMAL}>-------------------------------- </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" ${NORMAL}>Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} </text>`);
|
|
}
|
|
parts.push(`<text align="left" width="1" height="1">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} </text>`);
|
|
parts.push(`<text align="left" width="1" height="1" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} </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" ${NORMAL}>Paid: ${xmlEscape(paidLabel)} </text>`;
|
|
if (s.payment === 'cash') out += `<text align="left" ${NORMAL}>Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} </text>`;
|
|
return out;
|
|
},
|
|
footer: function (block) {
|
|
const text = block.text || (s.letterhead && s.letterhead.footer_text);
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (text) out += `<text align="center" ${NORMAL}>${xmlEscape(text)} </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) {
|
|
const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' },
|
|
body: xml
|
|
});
|
|
if (!res.ok) throw new Error('Printer HTTP error: ' + res.status);
|
|
const text = await res.text();
|
|
const doc = new DOMParser().parseFromString(text, 'text/xml');
|
|
const response = doc.getElementsByTagName('response')[0];
|
|
const success = response && response.getAttribute('success') === 'true';
|
|
if (!success) {
|
|
const code = response ? response.getAttribute('code') : null;
|
|
throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : ''));
|
|
}
|
|
}
|
|
|
|
function deliveryBlockRenderers(s, showPricing, logo) {
|
|
return {
|
|
// Matches the sale slip's header exactly (same logo, same 2x2
|
|
// title, same business-info block) — a delivery note is handed
|
|
// to the same customer and should look like the same company's
|
|
// paperwork, not a stripped-down variant.
|
|
logo: function () {
|
|
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
|
|
},
|
|
header_title: function (block) {
|
|
const title = block.text || 'WFSALES POS';
|
|
return '<feed line="1"/>'
|
|
+ `<text align="center" width="2" height="2">${xmlEscape(title)} </text>`
|
|
+ `<text align="center" ${NORMAL}>Delivery Note </text>`;
|
|
},
|
|
business_info: function () {
|
|
const parts = [];
|
|
// The POS letterhead record exposes `display_name`, not `name`
|
|
// — reading `.name` printed nothing at all on every slip.
|
|
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
|
|
if (bizName) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(bizName)} </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" ${NORMAL}>${addrLines.join(' ')} </text>`);
|
|
return parts.join('');
|
|
},
|
|
customer: function () {
|
|
let out = '';
|
|
if (s.customer && s.customer.company) out += `<text align="left" ${NORMAL}>Company: ${xmlEscape(s.customer.company)} </text>`;
|
|
out += `<text align="left" ${NORMAL}>Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} </text>`;
|
|
return out;
|
|
},
|
|
date: function () {
|
|
const d = new Date(s.createdAt);
|
|
return `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} </text>`;
|
|
},
|
|
items: function () {
|
|
const itemLines = s.items.map(function (it) {
|
|
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
|
|
const priceSuffix = showPricing ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : '';
|
|
return `${xmlEscape(it.description || it.sku)} ${xmlEscape(qtyLine + priceSuffix)} `;
|
|
}).join('');
|
|
return `<text align="left" ${NORMAL}>-------------------------------- ${itemLines}</text>`;
|
|
},
|
|
totals: function () {
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (showPricing) out += `<text align="left" ${NORMAL}>TOTAL: R ${s.total.toFixed(2)} </text>`;
|
|
return out;
|
|
},
|
|
signature: function () {
|
|
return `<text align="left" ${NORMAL}>Received by: __________ Driver: __________ </text>`;
|
|
},
|
|
};
|
|
}
|
|
|
|
// `blocks` is the resolved block list for 'delivery'. `showPricing`
|
|
// is the combined "Show pricing" setting — it gates the per-item
|
|
// price and the totals block together (see slip-layout.js).
|
|
// `logo` is a pre-rasterized {width,height,base64} or null.
|
|
function buildEposDeliveryXml(s, blocks, showPricing, logo) {
|
|
const renderers = deliveryBlockRenderers(s, showPricing, 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>`;
|
|
}
|
|
|
|
// Mirrors the cashup page's "Print cash-up" button. Renders an
|
|
// ePOS-Print XML slip with the day's takings broken down by
|
|
// payment method + the operator's cash-counted variance. The
|
|
// cashup.html page collects the data and calls this — we just
|
|
// format the XML.
|
|
//
|
|
// `report` shape:
|
|
// {
|
|
// shiftLabel: "Today" | "Yesterday" | string,
|
|
// dateLabel: "04 Sep 2026",
|
|
// counts: { cash, card, eft, account },
|
|
// totals: { cash, card, eft, account },
|
|
// grand: number,
|
|
// cashCounted: number,
|
|
// variance: number,
|
|
// denoms: [{ label: "R200", value: 200, count: 2 }, ...],
|
|
// }
|
|
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)} </text>`
|
|
+ '<text align="center" width="1" height="1">CASH-UP REPORT </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" ${NORMAL}>${xmlEscape(lh.display_name)} </text>`);
|
|
if (lh.address) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(lh.address.split('\n')[0])} </text>`);
|
|
if (lh.phone) parts.push(`<text align="center" ${NORMAL}>Tel: ${xmlEscape(lh.phone)} </text>`);
|
|
if (lh.vat_number) parts.push(`<text align="center" ${NORMAL}>VAT: ${xmlEscape(lh.vat_number)} </text>`);
|
|
return parts.join('');
|
|
},
|
|
shift_date: function () {
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (r.shiftLabel || r.dateLabel) out += `<text align="left" ${NORMAL}>Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} </text>`;
|
|
if (r.counts) out += `<text align="left" ${NORMAL}>Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} </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" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>PAYMENT METHOD BREAKDOWN </text>`
|
|
+ `<text align="left" ${NORMAL}>${lines.join(' ')} </text>`;
|
|
},
|
|
grand_total: function () {
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" width="1" height="1" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} </text>`;
|
|
},
|
|
cash_reconciliation: function () {
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>CASH RECONCILIATION </text>`
|
|
+ `<text align="left" ${NORMAL}>Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} </text>`
|
|
+ `<text align="left" ${NORMAL}>Cash counted: R ${fmtMoney(r.cashCounted || 0)} </text>`
|
|
+ `<text align="left" width="1" height="1" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)} </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" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>DENOMINATIONS COUNTED </text>`
|
|
+ `<text align="left" ${NORMAL}>${dlines.join(' ')} </text>`;
|
|
},
|
|
footer: function (block) {
|
|
const text = block.text || lh.footer_text;
|
|
if (!text) return '';
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="center" ${NORMAL}>${xmlEscape(text)} </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 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.
|
|
function pad(n, w) {
|
|
const s = String(n == null ? 0 : n);
|
|
return s.length >= w ? s : (' '.repeat(w - s.length) + s);
|
|
}
|
|
function fmtMoney(n) {
|
|
return (Number(n) || 0).toFixed(2);
|
|
}
|
|
|
|
// Test print — used by the Settings page's "Test print" button to
|
|
// verify the printer is reachable + the letterhead renders. Builds
|
|
// a tiny "Sample slip" with the active letterhead and pushes it
|
|
// through whichever transport the till is currently configured to
|
|
// use. Throws on failure (caller surfaces the error).
|
|
async function printTest(letterhead) {
|
|
const settings = getSettings();
|
|
const lh = letterhead || {};
|
|
const now = new Date();
|
|
const sale = {
|
|
createdAt: now.toISOString(),
|
|
localId: 'TEST-' + now.getTime().toString(36).toUpperCase(),
|
|
payment: 'cash',
|
|
cashGiven: 0,
|
|
change: 0,
|
|
gross: 0,
|
|
discount: { kind: 'none', input: 0, amount: 0 },
|
|
sub: 0,
|
|
vat: 0,
|
|
total: 0,
|
|
items: [],
|
|
customer: null,
|
|
letterhead: lh,
|
|
};
|
|
// Render the regular sale XML — it'll show the letterhead +
|
|
// payment line + footer, which is what the operator wants to
|
|
// eyeball when they tap "Test print". We don't need to swap in
|
|
// a custom test layout.
|
|
const logo = await letterheadLogoRaster(lh, 384);
|
|
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.');
|
|
}
|
|
await printViaNetwork(settings.ip, xml);
|
|
return 'Printed · ' + settings.ip;
|
|
}
|
|
// Bluetooth — the existing epos-print.js doesn't speak BT,
|
|
// only the receipt page does. Surface a clear message rather
|
|
// than pretending.
|
|
throw new Error('Test print on Bluetooth is not supported here — use the till\'s Print slip button.');
|
|
}
|
|
|
|
return { getSettings, setSettings, imageToRaster, letterheadLogoRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml, buildEposCashupXml, printTest };
|
|
})();
|