Files
timer-app/backend/receipt.js
T
Kerim (portable/claudecode)andClaude Opus 5 8c5cdf16c4
Deploy auf den Timer-Server / deploy (push) Successful in 15s
Strichcode ohne Beschriftung und ohne Klarschrift drucken
Die Zeile "Artikel fuer die Kasse" und die Klarschrift, die der Drucker unter
den Code setzt, fallen weg. Beide zeigten einem Kunden einen internen
Artikelnamen, der ihn nichts angeht - der Code steht jetzt fuer sich.

Die Klarschrift bleibt nur dort, wo sie der einzige Informationstraeger ist:
passt der Name nicht auf die Papierbreite, wird gar kein Code gedruckt und der
Name allein bleibt uebrig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 13:23:55 +02:00

208 lines
7.6 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, 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 };