Bon fuer die Kasse auf einem USB-Bondrucker ausgeben
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e83999950
commit
70756f3762
@@ -55,3 +55,25 @@ LOGIN_WINDOW_MIN=15
|
||||
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
|
||||
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
|
||||
RATE_PER_10MIN=10
|
||||
|
||||
# --- Bondrucker ------------------------------------------------------------
|
||||
# Der Bondrucker haengt per USB an einem Raspberry Pi. Auf dem Pi laeuft die
|
||||
# Bruecke aus deploy/bondrucker/ und nimmt fertige Druckdaten per HTTP an.
|
||||
# Einrichtung des Pi: siehe deploy/bondrucker/README.md
|
||||
#
|
||||
# Bleibt PRINTER_HOST leer, ist die Druckfunktion aus - die App laeuft dann
|
||||
# genau wie vorher, nur ohne Bon-Knoepfe.
|
||||
|
||||
PRINTER_HOST=
|
||||
PRINTER_PORT=9100
|
||||
|
||||
# Muss mit PRINT_TOKEN aus /etc/bondrucker/env auf dem Pi uebereinstimmen.
|
||||
# install.sh gibt den Wert am Ende aus.
|
||||
PRINTER_TOKEN=
|
||||
|
||||
# Zeichen je Zeile. 42 = 80-mm-Papier (Standard), 32 = 58-mm-Papier.
|
||||
PRINTER_WIDTH=42
|
||||
|
||||
# Abbruch, wenn der Pi nicht antwortet. Lieber kurz: ein haengender Drucker
|
||||
# darf das Beenden eines Timers nicht blockieren.
|
||||
PRINTER_TIMEOUT_MS=6000
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* ESC/POS - Bausteine fuer den Bondrucker
|
||||
*
|
||||
* Erzeugt die Bytefolge, die ein Thermo-Bondrucker versteht (getestet mit einem
|
||||
* Epson TM-T88IV). Bewusst ohne Fremdpaket: gebraucht werden ein Dutzend
|
||||
* Steuersequenzen, und der Drucker ist das Letzte, was wegen eines
|
||||
* Paket-Updates ploetzlich Buchstabensalat ausspucken soll.
|
||||
*
|
||||
* Zeichensatz: Der Drucker kennt kein UTF-8. Umgestellt wird auf Codepage 16
|
||||
* (WPC1252) - die deckt die deutschen Umlaute ab und ist ab Werk in praktisch
|
||||
* jedem Epson-Bondrucker vorhanden. Alles, was dort fehlt, wird lesbar
|
||||
* ersetzt statt als Muell gedruckt.
|
||||
*/
|
||||
|
||||
const ESC = 0x1b;
|
||||
const GS = 0x1d;
|
||||
|
||||
// Codepage 16 = WPC1252. Die Zeichen decken sich dort mit Latin-1, mit
|
||||
// Ausnahme des Euro-Zeichens (0x80). Deshalb reicht die Latin-1-Kodierung
|
||||
// von Node plus diese eine Sonderbehandlung.
|
||||
const CODEPAGE_WPC1252 = 16;
|
||||
|
||||
/** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */
|
||||
const ERSATZ = {
|
||||
'€': '\x80',
|
||||
'„': '"', '“': '"', '”': '"', '‚': "'", '‘': "'", '’': "'",
|
||||
'–': '-', '—': '-', '…': '...', ' ': ' ',
|
||||
'\t': ' '
|
||||
};
|
||||
|
||||
function encodeText(text) {
|
||||
let s = String(text == null ? '' : text);
|
||||
for (const [von, nach] of Object.entries(ERSATZ)) {
|
||||
s = s.split(von).join(nach);
|
||||
}
|
||||
// Was danach ausserhalb von Latin-1 liegt (Emoji, kyrillisch, ...), wuerde
|
||||
// als zufaelliges Zeichen gedruckt. Lieber ein sichtbares Fragezeichen.
|
||||
s = s.replace(/[^\x00-\xff]/g, '?');
|
||||
return Buffer.from(s, 'latin1');
|
||||
}
|
||||
|
||||
class Bon {
|
||||
/** @param {number} width Zeichen je Zeile (Font A: 42 bei 80 mm, 32 bei 58 mm) */
|
||||
constructor(width = 42) {
|
||||
this.width = width;
|
||||
this.parts = [];
|
||||
}
|
||||
|
||||
raw(...bytes) {
|
||||
this.parts.push(Buffer.from(bytes));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Grundzustand herstellen - der Drucker merkt sich Einstellungen sonst. */
|
||||
init() {
|
||||
return this.raw(ESC, 0x40) // ESC @ Zuruecksetzen
|
||||
.raw(ESC, 0x74, CODEPAGE_WPC1252); // ESC t Zeichensatz
|
||||
}
|
||||
|
||||
/** @param {'left'|'center'|'right'} how */
|
||||
align(how) {
|
||||
const n = how === 'center' ? 1 : how === 'right' ? 2 : 0;
|
||||
return this.raw(ESC, 0x61, n);
|
||||
}
|
||||
|
||||
bold(on) {
|
||||
return this.raw(ESC, 0x45, on ? 1 : 0);
|
||||
}
|
||||
|
||||
underline(on) {
|
||||
return this.raw(ESC, 0x2d, on ? 1 : 0);
|
||||
}
|
||||
|
||||
/** Zeichengroesse als Vielfaches, 1..4 je Richtung. */
|
||||
size(w = 1, h = 1) {
|
||||
const clamp = v => Math.max(1, Math.min(4, parseInt(v) || 1)) - 1;
|
||||
return this.raw(GS, 0x21, (clamp(w) << 4) | clamp(h));
|
||||
}
|
||||
|
||||
text(s) {
|
||||
this.parts.push(encodeText(s));
|
||||
return this;
|
||||
}
|
||||
|
||||
line(s = '') {
|
||||
return this.text(s + '\n');
|
||||
}
|
||||
|
||||
/** Zeile mit linkem und rechtem Block - die Trennlinie fuer Betraege. */
|
||||
columns(left, right, width = this.width) {
|
||||
const l = String(left == null ? '' : left);
|
||||
const r = String(right == null ? '' : right);
|
||||
const luecke = width - l.length - r.length;
|
||||
if (luecke < 1) {
|
||||
// Passt nicht nebeneinander: rechts buendig in die naechste Zeile.
|
||||
return this.line(l).line(r.padStart(width));
|
||||
}
|
||||
return this.line(l + ' '.repeat(luecke) + r);
|
||||
}
|
||||
|
||||
/** Text mittig, ohne den Drucker auf zentriert umzustellen. */
|
||||
center(s) {
|
||||
const t = String(s == null ? '' : s);
|
||||
if (t.length >= this.width) return this.line(t);
|
||||
return this.line(' '.repeat(Math.floor((this.width - t.length) / 2)) + t);
|
||||
}
|
||||
|
||||
hr(zeichen = '-') {
|
||||
return this.line(zeichen.repeat(this.width));
|
||||
}
|
||||
|
||||
/** Langen Text auf die Zeilenbreite umbrechen. */
|
||||
wrap(s, width = this.width) {
|
||||
const worte = String(s == null ? '' : s).split(/\s+/).filter(Boolean);
|
||||
let zeile = '';
|
||||
for (const wort of worte) {
|
||||
if (!zeile) {
|
||||
zeile = wort;
|
||||
} else if ((zeile + ' ' + wort).length <= width) {
|
||||
zeile += ' ' + wort;
|
||||
} else {
|
||||
this.line(zeile);
|
||||
zeile = wort;
|
||||
}
|
||||
while (zeile.length > width) {
|
||||
this.line(zeile.slice(0, width));
|
||||
zeile = zeile.slice(width);
|
||||
}
|
||||
}
|
||||
if (zeile) this.line(zeile);
|
||||
return this;
|
||||
}
|
||||
|
||||
feed(n = 1) {
|
||||
return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Papier abschneiden. Vorher wird gefuettert: der Schneidekopf sitzt
|
||||
* einige Millimeter hinter dem Druckkopf, ohne Vorschub landet der Schnitt
|
||||
* mitten im Text.
|
||||
*/
|
||||
cut() {
|
||||
return this.feed(4).raw(GS, 0x56, 0x42, 0x00);
|
||||
}
|
||||
|
||||
build() {
|
||||
return Buffer.concat(this.parts);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Bon, encodeText };
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Anbindung des Bondruckers
|
||||
*
|
||||
* Der Drucker haengt per USB an einem Raspberry Pi im Laden, nicht an diesem
|
||||
* Server. Auf dem Pi laeuft die Bruecke aus deploy/bondrucker/, die fertige
|
||||
* ESC/POS-Daten per HTTP annimmt. Hier entsteht nur der Aufruf.
|
||||
*
|
||||
* Zwei Grundsaetze, die den Rest erklaeren:
|
||||
*
|
||||
* 1. Drucken darf die Zeiterfassung nie aufhalten. Ein leerer Papierrollen-
|
||||
* halter ist ein Papierproblem, kein Datenproblem: der Vorgang ist da
|
||||
* bereits gespeichert, der Bon laesst sich aus der Historie nachdrucken.
|
||||
* 2. Kein Fremdpaket. Node bringt einen HTTP-Client mit.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const HOST = (process.env.PRINTER_HOST || '').trim();
|
||||
const PORT = parseInt(process.env.PRINTER_PORT) || 9100;
|
||||
const TOKEN = (process.env.PRINTER_TOKEN || '').trim();
|
||||
const TIMEOUT_MS = parseInt(process.env.PRINTER_TIMEOUT_MS) || 6000;
|
||||
const WIDTH = parseInt(process.env.PRINTER_WIDTH) || 42;
|
||||
|
||||
// Zuletzt gesehener Zustand - fuer /health und die Anzeige in den Einstellungen.
|
||||
let lastError = null;
|
||||
let lastPrintAt = null;
|
||||
|
||||
function isConfigured() {
|
||||
return HOST.length > 0;
|
||||
}
|
||||
|
||||
function config() {
|
||||
return { host: HOST, port: PORT, width: WIDTH, timeoutMs: TIMEOUT_MS, tokenSet: !!TOKEN };
|
||||
}
|
||||
|
||||
function state() {
|
||||
return {
|
||||
configured: isConfigured(),
|
||||
host: HOST || null,
|
||||
port: HOST ? PORT : null,
|
||||
lastError,
|
||||
lastPrintAt
|
||||
};
|
||||
}
|
||||
|
||||
/** Ein Aufruf gegen die Bruecke. Loest nie mit einem Fehler auf, der den
|
||||
* Aufrufer zwingt, ihn zu behandeln - stattdessen {ok, error}. */
|
||||
function request(pfad, method, body) {
|
||||
return new Promise((resolve) => {
|
||||
if (!isConfigured()) {
|
||||
return resolve({ ok: false, error: 'Kein Drucker eingerichtet (PRINTER_HOST fehlt).' });
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/octet-stream';
|
||||
headers['Content-Length'] = body.length;
|
||||
}
|
||||
if (TOKEN) headers['X-Print-Token'] = TOKEN;
|
||||
|
||||
const req = http.request({ host: HOST, port: PORT, path: pfad, method, headers }, (res) => {
|
||||
const teile = [];
|
||||
res.on('data', d => teile.push(d));
|
||||
res.on('end', () => {
|
||||
const roh = Buffer.concat(teile).toString('utf8');
|
||||
let daten = null;
|
||||
try { daten = JSON.parse(roh); } catch (e) { /* Bruecke antwortet sonst nichts */ }
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return resolve({ ok: true, data: daten });
|
||||
}
|
||||
const meldung = (daten && daten.error) || `Drucker antwortet mit HTTP ${res.statusCode}`;
|
||||
resolve({ ok: false, error: meldung, data: daten });
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(TIMEOUT_MS, () => {
|
||||
req.destroy(new Error(`Drucker antwortet nicht innerhalb von ${TIMEOUT_MS} ms`));
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
// ECONNREFUSED/EHOSTUNREACH sind der Normalfall, wenn der Pi aus ist.
|
||||
const meldung = err.code === 'ECONNREFUSED'
|
||||
? `Druckdienst auf ${HOST}:${PORT} nimmt keine Verbindung an.`
|
||||
: err.code === 'EHOSTUNREACH' || err.code === 'ENETUNREACH'
|
||||
? `Drucker-Pi ${HOST} ist im Netz nicht erreichbar.`
|
||||
: (err.message || String(err));
|
||||
resolve({ ok: false, error: meldung });
|
||||
});
|
||||
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** Zustand der Bruecke abfragen (Geraet da? beschreibbar?). */
|
||||
async function status() {
|
||||
const res = await request('/status', 'GET');
|
||||
if (!res.ok) {
|
||||
lastError = res.error;
|
||||
return { ok: false, error: res.error, ...state() };
|
||||
}
|
||||
return { ok: true, printer: res.data, ...state() };
|
||||
}
|
||||
|
||||
/** Rohe ESC/POS-Daten an den Drucker geben. */
|
||||
async function print(buffer) {
|
||||
const res = await request('/print', 'POST', buffer);
|
||||
if (res.ok) {
|
||||
lastError = null;
|
||||
lastPrintAt = Date.now();
|
||||
} else {
|
||||
lastError = res.error;
|
||||
console.warn('[drucker] Druck fehlgeschlagen:', res.error);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
module.exports = { isConfigured, config, state, status, print, WIDTH };
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 };
|
||||
+226
-5
@@ -16,6 +16,8 @@ const bcrypt = require('bcrypt');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
const oidc = require('./oidc');
|
||||
const printer = require('./printer');
|
||||
const { buildReceipt } = require('./receipt');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -142,6 +144,13 @@ async function initDatabase() {
|
||||
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
|
||||
}
|
||||
|
||||
// Bon-Texte: Kopf bleibt leer (der Laden traegt ihn selbst ein), der
|
||||
// Fuss steht auf dem, was der Kunde damit tun soll.
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_header','')`);
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_footer',?)`,
|
||||
['Bitte an der Kasse bezahlen.\nVielen Dank fuer Ihren Besuch!']);
|
||||
await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('print_auto_on_stop','1')`);
|
||||
|
||||
console.log('Datenbank bereit');
|
||||
} catch (err) {
|
||||
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
|
||||
@@ -154,6 +163,11 @@ async function initDatabase() {
|
||||
// die Anzeige mit, der verbindliche Betrag entsteht hier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setSetting(key, value) {
|
||||
await dbRun(`INSERT INTO settings (key,value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [key, String(value)]);
|
||||
}
|
||||
|
||||
async function getRate() {
|
||||
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
|
||||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
@@ -163,6 +177,18 @@ async function getRate() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */
|
||||
async function getPrintSettings() {
|
||||
const rows = await dbAll(
|
||||
`SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`);
|
||||
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
return {
|
||||
receiptHeader: map.receipt_header || '',
|
||||
receiptFooter: map.receipt_footer || '',
|
||||
printAutoOnStop: map.print_auto_on_stop !== '0'
|
||||
};
|
||||
}
|
||||
|
||||
/** Jede angefangene Einheit wird voll berechnet. */
|
||||
function calcAmount(ms, rateAmount, rateInterval) {
|
||||
if (!ms || ms <= 0) return 0;
|
||||
@@ -192,6 +218,39 @@ async function decorate(timer, rate) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bondruck
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Baut den Bon und schickt ihn an den Drucker-Pi.
|
||||
*
|
||||
* Gibt immer ein Ergebnis zurueck und wirft nie: ein Druckfehler ist ein
|
||||
* Papierproblem, kein Datenproblem. Der Vorgang ist zu diesem Zeitpunkt schon
|
||||
* gespeichert, der Bon laesst sich aus der Historie nachdrucken.
|
||||
*
|
||||
* @returns {Promise<{printed: boolean, error: string|null}>}
|
||||
*/
|
||||
async function printReceipt(vorgang) {
|
||||
if (!printer.isConfigured()) {
|
||||
return { printed: false, error: 'Kein Bondrucker eingerichtet.' };
|
||||
}
|
||||
try {
|
||||
const texte = await getPrintSettings();
|
||||
const daten = buildReceipt({
|
||||
...vorgang,
|
||||
header: texte.receiptHeader,
|
||||
footer: texte.receiptFooter,
|
||||
width: printer.WIDTH
|
||||
});
|
||||
const res = await printer.print(daten);
|
||||
return { printed: !!res.ok, error: res.ok ? null : res.error };
|
||||
} catch (err) {
|
||||
console.error('[drucker] Bon konnte nicht gebaut werden:', err);
|
||||
return { printed: false, error: 'Bon konnte nicht erzeugt werden.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sitzungsspeicher
|
||||
//
|
||||
@@ -283,6 +342,16 @@ app.use(session({
|
||||
|
||||
app.use(express.static(path.join(__dirname, '../frontend')));
|
||||
|
||||
// Papierbremse: ein hektisch mehrfach getippter Knopf soll nicht die halbe
|
||||
// Rolle ausspucken. Grosszuegig bemessen, im Alltag unauffaellig.
|
||||
const printLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Druckauftraege in kurzer Zeit. Bitte kurz warten.' }
|
||||
});
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session.authenticated) return next();
|
||||
res.status(401).json({ error: 'Nicht angemeldet' });
|
||||
@@ -377,7 +446,16 @@ app.get('/api/timers', requireAuth, wrap(async (req, res) => {
|
||||
const rate = await getRate();
|
||||
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
|
||||
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
|
||||
res.json({ timers, rate, serverNow: Date.now() });
|
||||
// Die beiden Druck-Angaben steuern die Bon-Knoepfe und den Hinweistext in
|
||||
// der Beenden-Rueckfrage. Sie kommen hier mit, damit die Timer-Ansicht
|
||||
// dafuer keinen zweiten Aufruf braucht.
|
||||
const druck = await getPrintSettings();
|
||||
res.json({
|
||||
timers, rate,
|
||||
printerConfigured: printer.isConfigured(),
|
||||
printAutoOnStop: druck.printAutoOnStop,
|
||||
serverNow: Date.now()
|
||||
});
|
||||
}));
|
||||
|
||||
app.post('/api/timers', requireAuth, wrap(async (req, res) => {
|
||||
@@ -425,7 +503,13 @@ app.patch('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ timer: await decorate(row, await getRate()) });
|
||||
}));
|
||||
|
||||
/** Beenden: schreibt die Session in die Historie und entfernt den Timer. */
|
||||
/**
|
||||
* Beenden: schreibt die Session in die Historie, entfernt den Timer und
|
||||
* druckt - sofern eingerichtet - den Bon fuer die Kasse.
|
||||
*
|
||||
* Reihenfolge ist Absicht: erst speichern, dann drucken. Andersherum koennte
|
||||
* ein Druckfehler einen bereits erfassten Vorgang verlieren.
|
||||
*/
|
||||
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
@@ -448,7 +532,67 @@ app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
|
||||
);
|
||||
await dbRun(`DELETE FROM active_timers WHERE id = ?`, [t.id]);
|
||||
|
||||
res.json({ saved: true, id: result.lastID, duration, amount });
|
||||
// Drucken, wenn es in den Einstellungen so hinterlegt ist - oder wenn der
|
||||
// Aufruf es ausdruecklich verlangt bzw. ausdruecklich abwaehlt.
|
||||
const texte = await getPrintSettings();
|
||||
const wunsch = req.body && typeof req.body.print === 'boolean' ? req.body.print : texte.printAutoOnStop;
|
||||
|
||||
let druck = { printed: false, error: null };
|
||||
if (wunsch && printer.isConfigured()) {
|
||||
druck = await printReceipt({
|
||||
kind: 'final',
|
||||
belegNr: result.lastID,
|
||||
customerName: t.customer_name || '',
|
||||
startedAt: t.created_at,
|
||||
endedAt: endTime,
|
||||
durationMs: duration,
|
||||
amount,
|
||||
rateAmount: rate.rateAmount,
|
||||
rateInterval: rate.rateInterval
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
saved: true, id: result.lastID, duration, amount,
|
||||
printed: druck.printed,
|
||||
printError: druck.error,
|
||||
printerConfigured: printer.isConfigured()
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* Zwischenbon fuer einen laufenden Timer.
|
||||
*
|
||||
* Der eigentliche Anwendungsfall: der Kunde will jetzt zahlen gehen. Er
|
||||
* bekommt den aktuellen Stand auf Papier, der Timer laeuft davon unberuehrt
|
||||
* weiter - beendet wird er erst, wenn der Platz wirklich frei ist.
|
||||
*/
|
||||
app.post('/api/timers/:id/receipt', requireAuth, printLimiter, wrap(async (req, res) => {
|
||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'Timer nicht gefunden' });
|
||||
if (!printer.isConfigured()) {
|
||||
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
|
||||
}
|
||||
|
||||
const rate = await getRate();
|
||||
const dauer = elapsedMs(t);
|
||||
const betrag = calcAmount(dauer, rate.rateAmount, rate.rateInterval);
|
||||
|
||||
const druck = await printReceipt({
|
||||
kind: 'interim',
|
||||
belegNr: `T-${t.id}`,
|
||||
customerName: t.customer_name || '',
|
||||
startedAt: t.created_at,
|
||||
endedAt: Date.now(),
|
||||
durationMs: dauer,
|
||||
amount: betrag,
|
||||
rateAmount: rate.rateAmount,
|
||||
rateInterval: rate.rateInterval,
|
||||
stillRunning: !!t.is_running
|
||||
});
|
||||
|
||||
if (!druck.printed) return res.status(502).json({ error: druck.error });
|
||||
res.json({ printed: true, duration: dauer, amount: betrag });
|
||||
}));
|
||||
|
||||
/** Verwerfen: Timer weg, nichts in der Historie. */
|
||||
@@ -462,7 +606,7 @@ app.delete('/api/timers/:id', requireAuth, wrap(async (req, res) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
res.json(await getRate());
|
||||
res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() });
|
||||
}));
|
||||
|
||||
app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
@@ -481,7 +625,55 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => {
|
||||
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(interval)]);
|
||||
|
||||
res.json(await getRate());
|
||||
// Bon-Texte sind optional: aeltere Clients schicken sie nicht mit und
|
||||
// sollen die vorhandenen Werte dann auch nicht leeren.
|
||||
const b = req.body || {};
|
||||
if (typeof b.receiptHeader === 'string') {
|
||||
await setSetting('receipt_header', b.receiptHeader.slice(0, 240).replace(/\r/g, ''));
|
||||
}
|
||||
if (typeof b.receiptFooter === 'string') {
|
||||
await setSetting('receipt_footer', b.receiptFooter.slice(0, 240).replace(/\r/g, ''));
|
||||
}
|
||||
if (typeof b.printAutoOnStop === 'boolean') {
|
||||
await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0');
|
||||
}
|
||||
|
||||
res.json({ ...await getRate(), ...await getPrintSettings(), printer: printer.state() });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drucker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Erreichbarkeit und Zustand der Bruecke auf dem Drucker-Pi. */
|
||||
app.get('/api/printer/status', requireAuth, wrap(async (req, res) => {
|
||||
if (!printer.isConfigured()) {
|
||||
return res.json({ configured: false, ok: false, error: 'Kein Bondrucker eingerichtet (PRINTER_HOST).' });
|
||||
}
|
||||
res.json({ configured: true, ...await printer.status(), config: printer.config() });
|
||||
}));
|
||||
|
||||
/** Probedruck - beantwortet die Frage "liegt es am Drucker oder an der App?". */
|
||||
app.post('/api/printer/test', requireAuth, printLimiter, wrap(async (req, res) => {
|
||||
if (!printer.isConfigured()) {
|
||||
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
|
||||
}
|
||||
const rate = await getRate();
|
||||
const jetzt = Date.now();
|
||||
const druck = await printReceipt({
|
||||
kind: 'interim',
|
||||
belegNr: 'PROBE',
|
||||
customerName: 'Probedruck',
|
||||
startedAt: jetzt - 25 * 60000,
|
||||
endedAt: jetzt,
|
||||
durationMs: 25 * 60000,
|
||||
amount: calcAmount(25 * 60000, rate.rateAmount, rate.rateInterval),
|
||||
rateAmount: rate.rateAmount,
|
||||
rateInterval: rate.rateInterval,
|
||||
stillRunning: false
|
||||
});
|
||||
if (!druck.printed) return res.status(502).json({ error: druck.error });
|
||||
res.json({ printed: true });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -532,6 +724,34 @@ app.delete('/api/sessions/:id', requireAuth, wrap(async (req, res) => {
|
||||
res.json({ success: true });
|
||||
}));
|
||||
|
||||
/** Bon nachdrucken - fuer den Fall "Papier war leer" oder "Kunde hat ihn verlegt". */
|
||||
app.post('/api/sessions/:id/receipt', requireAuth, printLimiter, wrap(async (req, res) => {
|
||||
const s = await dbGet(`SELECT * FROM sessions WHERE id = ?`, [req.params.id]);
|
||||
if (!s) return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
if (!printer.isConfigured()) {
|
||||
return res.status(503).json({ error: 'Kein Bondrucker eingerichtet.' });
|
||||
}
|
||||
|
||||
// Altbestand aus der Zeit vor der Tarif-Spalte: dann den heutigen Tarif
|
||||
// ausweisen - der Betrag selbst stammt weiter aus dem Datensatz.
|
||||
const rate = await getRate();
|
||||
|
||||
const druck = await printReceipt({
|
||||
kind: 'final',
|
||||
belegNr: s.id,
|
||||
customerName: s.customer_name || '',
|
||||
startedAt: s.start_time,
|
||||
endedAt: s.end_time,
|
||||
durationMs: s.duration,
|
||||
amount: s.amount,
|
||||
rateAmount: s.rate_amount != null ? s.rate_amount : rate.rateAmount,
|
||||
rateInterval: s.rate_interval != null ? s.rate_interval : rate.rateInterval
|
||||
});
|
||||
|
||||
if (!druck.printed) return res.status(502).json({ error: druck.error });
|
||||
res.json({ printed: true });
|
||||
}));
|
||||
|
||||
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
|
||||
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
|
||||
const { where, params } = dateFilter(req.query);
|
||||
@@ -588,6 +808,7 @@ app.get('/health', wrap(async (req, res) => {
|
||||
rateAmount: rate.rateAmount,
|
||||
rateInterval: rate.rateInterval,
|
||||
activeTimers: active.c,
|
||||
printer: printer.state(),
|
||||
uptimeSeconds: Math.floor(process.uptime())
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user