/** * 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 } = 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 {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(); // --- 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(); } /** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */ function toPlainText(buffer) { return 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(/\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 };