/** * Bon-Layout * * Baut aus einem abgeschlossenen oder laufenden Vorgang den Zettel, den der * Kunde mit an die Kasse nimmt. Die eine Zahl, auf die es ankommt - der zu * zahlende Betrag - steht deshalb gross und allein. * * Formatierung bewusst von Hand statt ueber Intl: TZ und Zahlenformat des * Bons sollen nicht davon abhaengen, mit welcher ICU-Variante das Node auf * dem Zielhost gebaut wurde. */ const { Bon, barcodeMaxLength } = require('./escpos'); const p2 = n => String(n).padStart(2, '0'); function fmtDate(ms) { if (!ms) return '-'; const d = new Date(Number(ms)); return `${p2(d.getDate())}.${p2(d.getMonth() + 1)}.${d.getFullYear()}`; } function fmtTime(ms) { if (!ms) return '-'; const d = new Date(Number(ms)); return `${p2(d.getHours())}:${p2(d.getMinutes())}`; } function fmtDuration(ms) { const s = Math.floor(Math.max(0, ms || 0) / 1000); return `${p2(Math.floor(s / 3600))}:${p2(Math.floor((s % 3600) / 60))}:${p2(s % 60)}`; } /** Deutsches Format mit Komma - "80,00 EUR". */ function fmtEuro(value) { const n = Number(value || 0); const [ganz, dez] = n.toFixed(2).split('.'); const mitPunkt = ganz.replace(/\B(?=(\d{3})+(?!\d))/g, '.'); return `${mitPunkt},${dez} EUR`; } /** Wie viele angefangene Einheiten berechnet wurden - erklaert den Betrag. */ function unitCount(ms, rateInterval) { if (!ms || ms <= 0) return 0; return Math.ceil(Math.ceil(ms / 60000) / (rateInterval || 10)); } /** * @param {object} data * @param {'final'|'interim'} data.kind Endbeleg oder Zwischenstand * @param {string|number} data.belegNr * @param {string} data.customerName * @param {number} data.startedAt Zeitstempel in ms * @param {number} data.endedAt Zeitstempel in ms (bei 'interim': jetzt) * @param {number} data.durationMs * @param {number} data.amount * @param {number} data.rateAmount * @param {number} data.rateInterval * @param {boolean} data.stillRunning * @param {string} data.header Kopfzeilen, durch \n getrennt * @param {string} data.footer Fusszeilen, durch \n getrennt * @param {string} data.barcode Artikelname fuer den Strichcode; leer = keiner * @param {number} data.width Zeichen je Zeile * @returns {Buffer} */ function buildReceipt(data) { const width = data.width || 42; const interim = data.kind === 'interim'; const bon = new Bon(width); bon.init(); // --- Kopf: Ladenname, gross und fett ------------------------------------ const kopfzeilen = String(data.header || '').split('\n').map(z => z.trim()).filter(Boolean); if (kopfzeilen.length) { bon.align('center').bold(true); bon.size(2, 2).line(kopfzeilen[0].slice(0, Math.floor(width / 2))); bon.size(1, 1); for (const zeile of kopfzeilen.slice(1)) bon.wrap(zeile); bon.bold(false).align('left'); bon.line(); } if (interim) { bon.align('center').bold(true).line('--- ZWISCHENSTAND ---').bold(false).align('left'); bon.line(); } // --- Belegkopf ---------------------------------------------------------- bon.hr(); bon.columns(interim ? `Zwischenbeleg ${data.belegNr}` : `Beleg-Nr. ${data.belegNr}`, `${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`); if (data.customerName) { bon.wrap(`Kunde: ${data.customerName}`); } bon.hr(); // --- Zeiten ------------------------------------------------------------- bon.columns('Beginn', `${fmtDate(data.startedAt)} ${fmtTime(data.startedAt)}`); bon.columns(interim ? 'Stand' : 'Ende', `${fmtDate(data.endedAt)} ${fmtTime(data.endedAt)}`); bon.columns('Dauer', fmtDuration(data.durationMs)); bon.line(); // --- Rechenweg: der Betrag soll nachvollziehbar sein --------------------- const einheiten = unitCount(data.durationMs, data.rateInterval); bon.columns('Tarif', `${fmtEuro(data.rateAmount)} / ${data.rateInterval} Min`); bon.columns('Angefangene Einheiten', String(einheiten)); bon.hr(); // --- Der Betrag --------------------------------------------------------- bon.line(); bon.align('center'); bon.bold(true).line('ZU ZAHLEN').bold(false); // Doppelte Breite heisst halbe Zeichenzahl je Zeile - deshalb hier keine // eigene Zentrierung rechnen, sondern die des Druckers nutzen. bon.size(2, 2).line(fmtEuro(data.amount)).size(1, 1); bon.align('left'); bon.line(); bon.hr(); // --- Strichcode fuer die Kasse ------------------------------------------ // Der Kassierer soll den Artikel nicht in einer Liste suchen muessen: der // Code enthaelt genau den Namen, unter dem die Kasse die Leistung fuehrt. // Bewusst ohne Beschriftung und ohne Klarschrift darunter - der Code steht // fuer sich, und auf einem Kundenbeleg hat ein interner Artikelname nichts // zu suchen. if (data.barcode) { bon.line(); bon.align('center'); if (data.barcode.length <= barcodeMaxLength(width)) { bon.barcode(data.barcode, { klarschrift: false }); } else { // Breiter als das Papier. Ein abgeschnittener Code wird von keinem // Scanner gelesen, also lieber gar keiner: der Name allein reicht // zum Eintippen. Passieren kann das nur, wenn die Papierbreite // nachtraeglich kleiner gestellt wurde. bon.bold(true).line(data.barcode).bold(false); } bon.align('left'); bon.line(); bon.hr(); } // --- Fuss --------------------------------------------------------------- bon.align('center'); if (interim) { bon.line(); if (data.stillRunning) { bon.line('Der Timer laeuft weiter.'); bon.line('Endbetrag erst beim Beenden.'); } else { bon.line('Timer pausiert.'); } } const fusszeilen = String(data.footer || '').split('\n').map(z => z.trim()).filter(Boolean); if (fusszeilen.length) { bon.line(); for (const zeile of fusszeilen) bon.wrap(zeile); } bon.align('left'); bon.cut(); return bon.build(); } /** * Strichcodes aus der Bytefolge herausloesen und durch ihren Inhalt ersetzen. * * Eigene Schleife statt eines Ausdrucks, weil bei "GS k" die Laenge der Daten * in einem Byte MITTEN in der Sequenz steht - wie weit zu springen ist, weiss * man also erst beim Lesen. Genau das kann ein regulaerer Ausdruck nicht. */ function stripBarcode(s) { let aus = ''; let i = 0; while (i < s.length) { if (s.charCodeAt(i) === 0x1d && s[i + 1] === 'k' && s.charCodeAt(i + 2) >= 65 && s.charCodeAt(i + 2) <= 73) { const laenge = s.charCodeAt(i + 3); const daten = s.substr(i + 4, laenge) .replace(/^\{[ABC]/, '') // Wahl des Zeichensatzes .split('{{').join('{'); // verdoppelte Klammer zuruecknehmen aus += `[Strichcode: ${daten}]\n`; i += 4 + laenge; continue; } aus += s[i]; i++; } return aus; } /** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */ function toPlainText(buffer) { return stripBarcode(buffer.toString('latin1')) .replace(/\x1b@/g, '') // Zuruecksetzen .replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte .replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse .replace(/\x1d[hwHf][\s\S]/g, '') // GS h/w/H/f Masse des Strichcodes .replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt .replace(/\x80/g, 'EUR') .replace(/\n{3,}/g, '\n\n') .trim(); } module.exports = { buildReceipt, toPlainText, fmtEuro, fmtDuration, unitCount };