Der Kunde soll mit einem Zettel zur Kasse gehen koennen, auf dem steht, was er zahlen muss. Drei Wege fuehren jetzt zu einem Bon: der Knopf "Bon" auf der Timer-Karte druckt den Zwischenstand und laesst den Timer weiterlaufen, "Beenden" druckt den Endbeleg, und in der Historie laesst sich jeder Beleg nachdrucken. Der Drucker haengt per USB an einem Raspberry Pi und ist damit vom Timer-Server aus nicht direkt erreichbar. Auf dem Pi laeuft deshalb eine kleine Bruecke (deploy/bondrucker/, nur Python-Standardbibliothek), die fertige ESC/POS-Daten per HTTP annimmt. Das Bon-Layout bleibt bewusst hier im Repo, damit Aenderungen daran nicht auf dem Pi angefasst werden muessen. Beim Beenden wird erst gespeichert und dann gedruckt. Eine leere Papierrolle ist damit ein Papierproblem und kein Datenproblem - der Vorgang steht in der Historie, die Oberflaeche sagt ausdruecklich, dass der Bon nicht gedruckt wurde, und nennt den Grund. Ohne PRINTER_HOST in der .env ist die Druckfunktion aus und die App verhaelt sich unveraendert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
5.5 KiB
JavaScript
156 lines
5.5 KiB
JavaScript
/**
|
|
* 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 };
|