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
@@ -12,7 +12,9 @@ Kleine, in sich geschlossene Node-App zur Zeiterfassung mit Kostenberechnung,
|
|||||||
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).
|
gedacht für den Einsatz im Ladengeschäft (Tresen-Tablet, mehrere Plätze parallel).
|
||||||
|
|
||||||
* **Keine externen Dienste.** Kein Cloud-Backend, keine API-Keys, keine Telemetrie,
|
* **Keine externen Dienste.** Kein Cloud-Backend, keine API-Keys, keine Telemetrie,
|
||||||
keine CDN-Einbindung. Die App macht **keine ausgehenden Netzwerkverbindungen**.
|
keine CDN-Einbindung. Ausgehend spricht die App nur mit zwei Zielen im
|
||||||
|
eigenen Netz: Authentik (Anmeldung) und — falls eingerichtet — dem
|
||||||
|
Bondrucker-Pi (Abschnitt 6). Kein Ziel außerhalb des eigenen Netzes.
|
||||||
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
|
* **Keine Datenbank-Installation nötig.** SQLite, eine einzelne Datei.
|
||||||
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
|
* **Keine Build-Kette.** Kein Webpack, kein TypeScript, kein npm run build.
|
||||||
Vanilla JS im Frontend, Express im Backend.
|
Vanilla JS im Frontend, Express im Backend.
|
||||||
@@ -205,6 +207,11 @@ Passwort fehlen — lieber laut scheitern als unsicher laufen.
|
|||||||
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
|
| `LOGIN_WINDOW_MIN` | `15` | Länge des Zeitfensters in Minuten |
|
||||||
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei |
|
| `DB_PATH` | `../data/timer.db` | Pfad zur SQLite-Datei |
|
||||||
| `RATE_PER_10MIN` | `10` | **Nur beim allerersten Start.** Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt. |
|
| `RATE_PER_10MIN` | `10` | **Nur beim allerersten Start.** Danach steht der Tarif in der Datenbank und wird über die Oberfläche gepflegt. |
|
||||||
|
| `PRINTER_HOST` | leer | IP des Bondrucker-Pi. **Leer = Druckfunktion aus**, die App verhält sich wie vorher |
|
||||||
|
| `PRINTER_PORT` | `9100` | Port der Brücke auf dem Pi |
|
||||||
|
| `PRINTER_TOKEN` | leer | muss mit `PRINT_TOKEN` in `/etc/bondrucker/env` übereinstimmen |
|
||||||
|
| `PRINTER_WIDTH` | `42` | Zeichen je Zeile: `42` bei 80-mm-Papier, `32` bei 58 mm |
|
||||||
|
| `PRINTER_TIMEOUT_MS` | `6000` | Abbruch, wenn der Pi nicht antwortet |
|
||||||
|
|
||||||
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
|
`SESSION_SECRET` muss ein *echter* Zufallswert sein. In der Vorgängerversion
|
||||||
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
|
stand dort versehentlich der literale Text `$(openssl rand -base64 32)` — die
|
||||||
@@ -250,6 +257,43 @@ Wichtig in beiden Fällen:
|
|||||||
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
|
* `/health` ist der Endpunkt für Health-Checks (antwortet **ohne** Anmeldung)
|
||||||
* Websockets werden **nicht** gebraucht
|
* Websockets werden **nicht** gebraucht
|
||||||
|
|
||||||
|
### Bondrucker
|
||||||
|
|
||||||
|
Vollständige Einrichtung: **`deploy/bondrucker/README.md`**. Hier nur das
|
||||||
|
Wesentliche.
|
||||||
|
|
||||||
|
Der Drucker hängt per USB an einem Raspberry Pi im Laden. Auf dem Pi läuft ein
|
||||||
|
kleiner Python-Dienst, der fertige ESC/POS-Daten per HTTP annimmt und in
|
||||||
|
`/dev/usb/lp0` schreibt. Der Bon selbst entsteht in dieser App
|
||||||
|
(`backend/receipt.js`) — auf dem Pi liegt bewusst keine Layout-Logik.
|
||||||
|
|
||||||
|
```
|
||||||
|
Timer-App ──HTTP POST /print──▶ Pi (bondrucker.service) ──▶ /dev/usb/lp0
|
||||||
|
receipt.js baut den Bon schiebt Bytes durch Epson TM-T88IV
|
||||||
|
```
|
||||||
|
|
||||||
|
Drei Wege führen zu einem Bon:
|
||||||
|
|
||||||
|
| Auslöser | Beleg | Timer danach |
|
||||||
|
|---|---|---|
|
||||||
|
| **🖨 Bon** auf der Timer-Karte | Zwischenstand | läuft unverändert weiter |
|
||||||
|
| **✓ Beenden** | Endbeleg | ist beendet und in der Historie |
|
||||||
|
| **🖨** in der Historie | Nachdruck desselben Belegs | — |
|
||||||
|
|
||||||
|
**Reihenfolge ist Absicht:** Beim Beenden wird erst gespeichert, dann gedruckt.
|
||||||
|
Ein leerer Papierhalter ist damit ein Papierproblem und kein Datenproblem — der
|
||||||
|
Vorgang steht in der Historie und lässt sich von dort nachdrucken. Die
|
||||||
|
Oberfläche sagt in dem Fall ausdrücklich, dass der Bon *nicht* gedruckt wurde.
|
||||||
|
|
||||||
|
Kopf- und Fußzeilen des Bons stehen in der Datenbank und werden unter
|
||||||
|
**Einstellungen** gepflegt, ebenso der Schalter „Bon beim Beenden automatisch
|
||||||
|
drucken". Der Probedruck dort beantwortet die Frage, ob es am Drucker oder an
|
||||||
|
der App liegt.
|
||||||
|
|
||||||
|
**Der Pi hängt per WLAN und DHCP im Netz.** Wechselt seine Adresse, druckt die
|
||||||
|
App nicht mehr, bis `PRINTER_HOST` angepasst ist — eine DHCP-Reservierung im
|
||||||
|
Router erspart das.
|
||||||
|
|
||||||
### Backup
|
### Backup
|
||||||
|
|
||||||
Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
|
Alles Wichtige steckt in **einer Datei**: `data/timer.db`.
|
||||||
@@ -275,7 +319,10 @@ timer-app/
|
|||||||
├── DEPLOY.md ← dieses Dokument
|
├── DEPLOY.md ← dieses Dokument
|
||||||
├── README.md ← Kurzanleitung für Anwender
|
├── README.md ← Kurzanleitung für Anwender
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js ← die gesamte Server-Logik (~470 Zeilen)
|
│ ├── server.js ← die gesamte Server-Logik
|
||||||
|
│ ├── escpos.js ← ESC/POS-Bausteine für den Bondrucker
|
||||||
|
│ ├── receipt.js ← Layout des Bons
|
||||||
|
│ ├── printer.js ← Aufruf der Brücke auf dem Pi
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── package-lock.json
|
│ ├── package-lock.json
|
||||||
│ ├── .env.example
|
│ ├── .env.example
|
||||||
@@ -288,7 +335,12 @@ timer-app/
|
|||||||
└── deploy/
|
└── deploy/
|
||||||
├── timer-app.service
|
├── timer-app.service
|
||||||
├── Caddyfile.example
|
├── Caddyfile.example
|
||||||
└── nginx.conf.example
|
├── nginx.conf.example
|
||||||
|
└── bondrucker/ ← Druckdienst für den Raspberry Pi
|
||||||
|
├── README.md ← Einrichtung des Pi
|
||||||
|
├── print-server.py ← der Dienst (nur Python-Standardbibliothek)
|
||||||
|
├── bondrucker.service
|
||||||
|
└── install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
`server.js` ist in beschriftete Abschnitte gegliedert (Konfiguration, Datenbank,
|
||||||
@@ -357,12 +409,16 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und
|
|||||||
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
|
| `PATCH` | `/api/timers/:id` | `{customerName}` umbenennen |
|
||||||
| `POST` | `/api/timers/:id/pause` | anhalten |
|
| `POST` | `/api/timers/:id/pause` | anhalten |
|
||||||
| `POST` | `/api/timers/:id/resume` | fortsetzen |
|
| `POST` | `/api/timers/:id/resume` | fortsetzen |
|
||||||
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern |
|
| `POST` | `/api/timers/:id/stop` | beenden **und** in Historie speichern; druckt den Bon, sofern eingerichtet. Optional `{print: bool}` überstimmt die Einstellung |
|
||||||
|
| `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet |
|
||||||
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
|
| `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern |
|
||||||
| `GET` | `/api/settings` | Tarif lesen |
|
| `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen |
|
||||||
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` |
|
| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` |
|
||||||
|
| `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers |
|
||||||
|
| `POST` | `/api/printer/test` | Probedruck |
|
||||||
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
| `GET` | `/api/sessions` | Historie; Parameter `from`, `to`, `q`, `limit`, `offset` |
|
||||||
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
| `DELETE` | `/api/sessions/:id` | Eintrag löschen |
|
||||||
|
| `POST` | `/api/sessions/:id/receipt` | Bon aus der Historie nachdrucken |
|
||||||
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
|
| `GET` | `/api/sessions/export.csv` | CSV; gleiche Filterparameter |
|
||||||
| `GET` | `/health` | Status, ohne Anmeldung |
|
| `GET` | `/health` | Status, ohne Anmeldung |
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ sich jederzeit nachträglich ändern, einfach im Feld überschreiben.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **❚❚ Pause** | Zeit anhalten, Betrag friert ein |
|
| **❚❚ Pause** | Zeit anhalten, Betrag friert ein |
|
||||||
| **▶ Weiter** | pausierten Timer fortsetzen |
|
| **▶ Weiter** | pausierten Timer fortsetzen |
|
||||||
| **✓ Beenden** | Vorgang abschließen und in der Historie speichern |
|
| **🖨 Bon** | Zwischenstand ausdrucken — der Timer läuft weiter |
|
||||||
|
| **✓ Beenden** | Vorgang abschließen, in der Historie speichern und Bon drucken |
|
||||||
| **✕ Verwerfen** | Timer wegwerfen, **nichts** wird gespeichert |
|
| **✕ Verwerfen** | Timer wegwerfen, **nichts** wird gespeichert |
|
||||||
|
|
||||||
Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
|
Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
|
||||||
@@ -43,6 +44,28 @@ Vor dem Beenden und dem Verwerfen kommt eine Rückfrage mit Zeit und Betrag.
|
|||||||
Wanduhr. Die Karte zeigt, seit wann er läuft. Wer die Zeit korrigieren will,
|
Wanduhr. Die Karte zeigt, seit wann er läuft. Wer die Zeit korrigieren will,
|
||||||
verwirft den Timer und erfasst ihn neu.
|
verwirft den Timer und erfasst ihn neu.
|
||||||
|
|
||||||
|
## Bon für die Kasse
|
||||||
|
|
||||||
|
Auf dem Bon steht, was der Kunde zahlen muss — mit Beginn, Ende, Dauer und dem
|
||||||
|
Tarif, damit der Betrag nachvollziehbar ist. Der Betrag selbst steht groß und
|
||||||
|
allein darunter.
|
||||||
|
|
||||||
|
Zwei Wege, je nachdem, wie der Kunde geht:
|
||||||
|
|
||||||
|
* **Der Platz wird frei.** **✓ Beenden** drücken. Der Vorgang wandert in die
|
||||||
|
Historie und der Bon kommt aus dem Drucker — damit geht der Kunde zur Kasse.
|
||||||
|
* **Der Kunde will zwischendurch zahlen, bleibt aber.** **🖨 Bon** drücken. Der
|
||||||
|
Zettel zeigt den Stand von jetzt und ist als *Zwischenstand* gekennzeichnet;
|
||||||
|
der Timer läuft unverändert weiter.
|
||||||
|
|
||||||
|
### Wenn nichts aus dem Drucker kommt
|
||||||
|
|
||||||
|
Der Vorgang ist trotzdem gespeichert — die App sagt in dem Fall ausdrücklich,
|
||||||
|
dass der Bon nicht gedruckt wurde, und nennt den Grund. Meist ist die Papierrolle
|
||||||
|
leer oder der Drucker aus. Nach dem Beheben: in der **Historie** beim Eintrag auf
|
||||||
|
**🖨** tippen, dann kommt derselbe Beleg noch einmal. Genauso, wenn ein Kunde
|
||||||
|
seinen Bon verlegt hat.
|
||||||
|
|
||||||
## Historie
|
## Historie
|
||||||
|
|
||||||
Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name,
|
Unter **📊 Historie** stehen alle abgeschlossenen Vorgänge mit Datum, Name,
|
||||||
@@ -59,11 +82,22 @@ Dauer und Betrag. Oben summieren drei Kacheln den gewählten Zeitraum.
|
|||||||
Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall,
|
Unter **⚙️ Einstellungen** wird der Tarif festgelegt: Betrag und Intervall,
|
||||||
zum Beispiel „10 € pro 10 Minuten". Die Vorschau rechnet Beispiele durch.
|
zum Beispiel „10 € pro 10 Minuten". Die Vorschau rechnet Beispiele durch.
|
||||||
|
|
||||||
|
Darunter steht, was auf dem Bon gedruckt wird:
|
||||||
|
|
||||||
|
* **Kopfzeilen** — Ladenname, Adresse, Telefonnummer. Die erste Zeile wird groß
|
||||||
|
gedruckt.
|
||||||
|
* **Fußzeilen** — der Hinweis unter dem Betrag, etwa „Bitte an der Kasse
|
||||||
|
bezahlen."
|
||||||
|
* **Bon beim Beenden automatisch drucken** — ausgeschaltet kommt ein Bon nur
|
||||||
|
noch über die 🖨-Knöpfe.
|
||||||
|
* **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der
|
||||||
|
Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand.
|
||||||
|
|
||||||
Wichtig zu wissen:
|
Wichtig zu wissen:
|
||||||
|
|
||||||
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
|
* **Jede angefangene Einheit wird voll berechnet.** 10 Minuten und 1 Sekunde
|
||||||
kosten bei einem 10-Minuten-Takt bereits zwei Einheiten.
|
kosten bei einem 10-Minuten-Takt bereits zwei Einheiten.
|
||||||
* Der Tarif gilt für **alle Geräte gemeinsam**.
|
* Der Tarif und die Bon-Texte gelten für **alle Geräte gemeinsam**.
|
||||||
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
|
* Eine Änderung wirkt sofort auf laufende und neue Timer. Bereits gespeicherte
|
||||||
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
|
Einträge in der Historie behalten den Tarif, der beim Beenden galt.
|
||||||
|
|
||||||
|
|||||||
@@ -55,3 +55,25 @@ LOGIN_WINDOW_MIN=15
|
|||||||
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
|
# Nur beim allerersten Start relevant: setzt den Anfangstarif.
|
||||||
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
|
# Danach wird der Tarif in der Datenbank gepflegt (Menue "Einstellungen").
|
||||||
RATE_PER_10MIN=10
|
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');
|
const path = require('path');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const oidc = require('./oidc');
|
const oidc = require('./oidc');
|
||||||
|
const printer = require('./printer');
|
||||||
|
const { buildReceipt } = require('./receipt');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -142,6 +144,13 @@ async function initDatabase() {
|
|||||||
console.log(`Standardtarif gesetzt: ${envAmount} EUR pro 10 Minuten`);
|
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');
|
console.log('Datenbank bereit');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
|
console.error('FATAL: Datenbank-Initialisierung fehlgeschlagen:', err.message);
|
||||||
@@ -154,6 +163,11 @@ async function initDatabase() {
|
|||||||
// die Anzeige mit, der verbindliche Betrag entsteht hier)
|
// 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() {
|
async function getRate() {
|
||||||
const rows = await dbAll(`SELECT key, value FROM settings WHERE key IN ('rate_amount','rate_interval')`);
|
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]));
|
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. */
|
/** Jede angefangene Einheit wird voll berechnet. */
|
||||||
function calcAmount(ms, rateAmount, rateInterval) {
|
function calcAmount(ms, rateAmount, rateInterval) {
|
||||||
if (!ms || ms <= 0) return 0;
|
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
|
// Sitzungsspeicher
|
||||||
//
|
//
|
||||||
@@ -283,6 +342,16 @@ app.use(session({
|
|||||||
|
|
||||||
app.use(express.static(path.join(__dirname, '../frontend')));
|
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) {
|
function requireAuth(req, res, next) {
|
||||||
if (req.session.authenticated) return next();
|
if (req.session.authenticated) return next();
|
||||||
res.status(401).json({ error: 'Nicht angemeldet' });
|
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 rate = await getRate();
|
||||||
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
|
const rows = await dbAll(`SELECT * FROM active_timers ORDER BY created_at ASC`);
|
||||||
const timers = await Promise.all(rows.map(t => decorate(t, rate)));
|
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) => {
|
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()) });
|
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) => {
|
app.post('/api/timers/:id/stop', requireAuth, wrap(async (req, res) => {
|
||||||
const t = await dbGet(`SELECT * FROM active_timers WHERE id = ?`, [req.params.id]);
|
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 (!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]);
|
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. */
|
/** 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) => {
|
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) => {
|
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',?)
|
await dbRun(`INSERT INTO settings (key,value) VALUES ('rate_interval',?)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(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 });
|
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. */
|
/** CSV fuer Excel: Semikolon, UTF-8-BOM, deutsches Dezimalkomma. */
|
||||||
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
|
app.get('/api/sessions/export.csv', requireAuth, wrap(async (req, res) => {
|
||||||
const { where, params } = dateFilter(req.query);
|
const { where, params } = dateFilter(req.query);
|
||||||
@@ -588,6 +808,7 @@ app.get('/health', wrap(async (req, res) => {
|
|||||||
rateAmount: rate.rateAmount,
|
rateAmount: rate.rateAmount,
|
||||||
rateInterval: rate.rateInterval,
|
rateInterval: rate.rateInterval,
|
||||||
activeTimers: active.c,
|
activeTimers: active.c,
|
||||||
|
printer: printer.state(),
|
||||||
uptimeSeconds: Math.floor(process.uptime())
|
uptimeSeconds: Math.floor(process.uptime())
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Bondrucker-Bruecke
|
||||||
|
|
||||||
|
Kleiner Dienst für den Raspberry Pi, an dem der Kassendrucker per USB hängt.
|
||||||
|
|
||||||
|
## Warum es ihn gibt
|
||||||
|
|
||||||
|
Der Bondrucker ist ein USB-Gerät (`/dev/usb/lp0`) und damit nur für den Rechner
|
||||||
|
sichtbar, an dem er steckt. Der Timer-Server läuft woanders. Diese Brücke nimmt
|
||||||
|
fertige ESC/POS-Daten per HTTP entgegen und schiebt sie in das Gerät — mehr
|
||||||
|
nicht. Der Bon selbst wird in der Timer-App gebaut (`backend/receipt.js`), damit
|
||||||
|
Layout-Änderungen nicht auf dem Pi angefasst werden müssen.
|
||||||
|
|
||||||
|
Nur Python-Standardbibliothek, kein pip, kein CUPS, kein node.
|
||||||
|
|
||||||
|
## Installation auf dem Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dateien auf den Pi kopieren
|
||||||
|
scp print-server.py bondrucker.service install.sh README.md pi@<PI-IP>:/tmp/bondrucker/
|
||||||
|
|
||||||
|
# Dort einrichten
|
||||||
|
ssh pi@<PI-IP>
|
||||||
|
cd /tmp/bondrucker && sudo bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`install.sh` legt an:
|
||||||
|
|
||||||
|
| Was | Wo |
|
||||||
|
|---|---|
|
||||||
|
| Dienstbenutzer `bondrucker` (Gruppe `lp`) | — |
|
||||||
|
| Programm | `/opt/bondrucker/print-server.py` |
|
||||||
|
| Token | `/etc/bondrucker/env` (`0640`, root:bondrucker) |
|
||||||
|
| udev-Regel | `/etc/udev/rules.d/99-bondrucker.rules` |
|
||||||
|
| Dienst | `bondrucker.service`, aktiviert und gestartet |
|
||||||
|
|
||||||
|
Am Ende gibt das Skript den Wert für `PRINTER_TOKEN` aus. Der gehört in die
|
||||||
|
`.env` des Timer-Servers.
|
||||||
|
|
||||||
|
## Timer-Server konfigurieren
|
||||||
|
|
||||||
|
In `backend/.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
PRINTER_HOST=192.168.8.157
|
||||||
|
PRINTER_PORT=9100
|
||||||
|
PRINTER_TOKEN=<Ausgabe von install.sh>
|
||||||
|
PRINTER_WIDTH=42
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach `systemctl restart timer-app`. Ist `PRINTER_HOST` leer, ist die
|
||||||
|
Druckfunktion aus und die App verhält sich wie vorher — ohne Bon-Knöpfe.
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zustand des Druckers
|
||||||
|
curl -s http://<PI-IP>:9100/status
|
||||||
|
|
||||||
|
# Aus der App heraus: Einstellungen -> "Probedruck"
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwortet `/status` mit `"ok": true`, ist das Gerät da und beschreibbar.
|
||||||
|
|
||||||
|
## Schnittstelle
|
||||||
|
|
||||||
|
| Aufruf | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `GET /status` | JSON mit Gerät, Beschreibbarkeit, letztem Fehler. HTTP 503, wenn kein Drucker da ist |
|
||||||
|
| `POST /print` | Rumpf = rohe ESC/POS-Bytes, Kopfzeile `X-Print-Token`. Antwortet `{"ok":true,"bytes":N}` |
|
||||||
|
|
||||||
|
Fehler kommen als HTTP 4xx/5xx mit `{"error": "..."}` zurück — die Timer-App
|
||||||
|
zeigt diesen Text unverändert an. „Papier leer" und „Deckel offen" landen als
|
||||||
|
`OSError` beim Schreiben und damit als HTTP 502 in der Anzeige.
|
||||||
|
|
||||||
|
## Betriebshinweise
|
||||||
|
|
||||||
|
* **Feste IP vergeben.** Der Pi hängt per WLAN und DHCP im Netz. Wechselt die
|
||||||
|
Adresse, druckt die App nicht mehr, bis `PRINTER_HOST` angepasst ist. Eine
|
||||||
|
DHCP-Reservierung im Router ist der einfachste Weg.
|
||||||
|
* **USB-Kabel neu gesteckt?** Aus `lp0` kann `lp1` werden. Der Dienst sucht
|
||||||
|
deshalb selbst nach `/dev/usb/lp*` — es ist nichts zu tun.
|
||||||
|
* **Ein Auftrag nach dem anderen.** Der Dienst serialisiert Druckaufträge, zwei
|
||||||
|
gleichzeitige Bons können sich nicht ineinanderschieben.
|
||||||
|
* **Netzgrenze.** Port 9100 sollte nur aus dem eigenen Netz erreichbar sein.
|
||||||
|
Das Token schützt gegen versehentliche, nicht gegen entschlossene Zugriffe.
|
||||||
|
|
||||||
|
## Fehlersuche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status bondrucker
|
||||||
|
journalctl -u bondrucker -f # jeder Auftrag wird protokolliert
|
||||||
|
|
||||||
|
# Am Pi direkt drucken, ohne App und ohne Dienst:
|
||||||
|
printf 'Test\n\n\n\n\x1dVB\x00' | sudo tee /dev/usb/lp0 > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| `/status` sagt `deviceExists: false` | Drucker aus oder USB-Kabel ab |
|
||||||
|
| `/status` sagt `writable: false` | udev-Regel nicht gegriffen — `sudo udevadm trigger`, Dienst neu starten |
|
||||||
|
| HTTP 403 | `PRINTER_TOKEN` in der `.env` weicht von `/etc/bondrucker/env` ab |
|
||||||
|
| App meldet „nimmt keine Verbindung an" | Dienst läuft nicht, oder falsche IP/Port |
|
||||||
|
| Umlaute als Kringel | Drucker beherrscht Codepage 16 (WPC1252) nicht — siehe `backend/escpos.js` |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bondrucker-Bruecke (ESC/POS ueber HTTP an den USB-Kassendrucker)
|
||||||
|
Documentation=file:///opt/bondrucker/README.md
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 /opt/bondrucker/print-server.py
|
||||||
|
|
||||||
|
# Laeuft NICHT als root. Der Dienstbenutzer ist nur deshalb in der Gruppe "lp",
|
||||||
|
# weil /dev/usb/lp0 dem Besitzer root und der Gruppe lp gehoert (Rechte 660).
|
||||||
|
User=bondrucker
|
||||||
|
Group=bondrucker
|
||||||
|
SupplementaryGroups=lp
|
||||||
|
|
||||||
|
# Konfiguration. Das Token steht in /etc/bondrucker/env, damit es nicht in der
|
||||||
|
# Versionsverwaltung landet - die Datei legt install.sh an.
|
||||||
|
Environment=PRINT_DEVICE=/dev/usb/lp0
|
||||||
|
Environment=PRINT_HOST=0.0.0.0
|
||||||
|
Environment=PRINT_PORT=9100
|
||||||
|
EnvironmentFile=-/etc/bondrucker/env
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
# --- Haertung ---------------------------------------------------------------
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
|
||||||
|
# Der Dienst braucht nirgends Schreibrecht - er schreibt ausschliesslich in die
|
||||||
|
# Geraetedatei, und die ist von ProtectSystem nicht betroffen.
|
||||||
|
PrivateDevices=false
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bondrucker-Bruecke auf dem Raspberry Pi einrichten.
|
||||||
|
#
|
||||||
|
# Aufruf auf dem Pi: sudo bash install.sh [TOKEN]
|
||||||
|
# Ohne TOKEN wird eins erzeugt und am Ende ausgegeben - dieser Wert gehoert
|
||||||
|
# anschliessend in die .env des Timer-Servers (PRINTER_TOKEN).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
QUELLE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ZIEL=/opt/bondrucker
|
||||||
|
TOKEN="${1:-}"
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Bitte mit sudo starten." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Dienstbenutzer"
|
||||||
|
if ! id bondrucker >/dev/null 2>&1; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin --groups lp bondrucker
|
||||||
|
echo " Benutzer 'bondrucker' angelegt (Gruppe lp)."
|
||||||
|
else
|
||||||
|
usermod -aG lp bondrucker
|
||||||
|
echo " Benutzer 'bondrucker' war schon da."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Programmdateien nach $ZIEL"
|
||||||
|
install -d -m 755 "$ZIEL"
|
||||||
|
install -m 755 "$QUELLE/print-server.py" "$ZIEL/print-server.py"
|
||||||
|
[ -f "$QUELLE/README.md" ] && install -m 644 "$QUELLE/README.md" "$ZIEL/README.md"
|
||||||
|
|
||||||
|
echo "--- Konfiguration"
|
||||||
|
install -d -m 750 /etc/bondrucker
|
||||||
|
if [ -f /etc/bondrucker/env ] && [ -z "$TOKEN" ]; then
|
||||||
|
echo " /etc/bondrucker/env besteht bereits - Token bleibt unveraendert."
|
||||||
|
else
|
||||||
|
[ -n "$TOKEN" ] || TOKEN="$(head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 32)"
|
||||||
|
printf 'PRINT_TOKEN=%s\n' "$TOKEN" > /etc/bondrucker/env
|
||||||
|
chmod 640 /etc/bondrucker/env
|
||||||
|
chown root:bondrucker /etc/bondrucker/env
|
||||||
|
echo " Token geschrieben."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- udev-Regel (Geraet ueberlebt das Aus- und Einstecken)"
|
||||||
|
cat > /etc/udev/rules.d/99-bondrucker.rules <<'RULE'
|
||||||
|
# USB-Bondrucker: Gruppe lp und Schreibrecht, damit der Dienst 'bondrucker'
|
||||||
|
# ohne root drucken kann. Gilt fuer alle usblp-Geraete.
|
||||||
|
SUBSYSTEM=="usbmisc", KERNEL=="lp[0-9]*", GROUP="lp", MODE="0660"
|
||||||
|
SUBSYSTEM=="usb", ATTR{bInterfaceClass}=="07", GROUP="lp", MODE="0660"
|
||||||
|
RULE
|
||||||
|
udevadm control --reload-rules || true
|
||||||
|
udevadm trigger --subsystem-match=usbmisc || true
|
||||||
|
|
||||||
|
echo "--- systemd"
|
||||||
|
install -m 644 "$QUELLE/bondrucker.service" /etc/systemd/system/bondrucker.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now bondrucker.service
|
||||||
|
sleep 1
|
||||||
|
systemctl --no-pager --lines=5 status bondrucker.service || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "--- Fertig"
|
||||||
|
echo "Status abfragen: curl -s http://$(hostname -I | awk '{print $1}'):9100/status"
|
||||||
|
echo "Token fuer die .env des Timer-Servers:"
|
||||||
|
echo " PRINTER_TOKEN=$(sed -n 's/^PRINT_TOKEN=//p' /etc/bondrucker/env)"
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Bondrucker-Bruecke — laeuft auf dem Raspberry Pi am Kassendrucker.
|
||||||
|
|
||||||
|
Warum es diesen Dienst ueberhaupt gibt: der Bondrucker haengt per USB am Pi
|
||||||
|
(/dev/usb/lp0) und ist damit vom Timer-Server aus nicht erreichbar. Dieser
|
||||||
|
Dienst nimmt fertige ESC/POS-Daten per HTTP entgegen und schiebt sie in das
|
||||||
|
Geraet. Die Timer-App baut den Bon, der Pi druckt ihn nur noch.
|
||||||
|
|
||||||
|
Bewusst nur Python-Standardbibliothek: auf dem Pi muss dafuer nichts
|
||||||
|
nachinstalliert werden (kein pip, kein CUPS, kein node).
|
||||||
|
|
||||||
|
Endpunkte
|
||||||
|
GET /status Zustand des Druckers als JSON (fuer Health-Checks)
|
||||||
|
POST /print Rumpf = rohe ESC/POS-Bytes, wird 1:1 an den Drucker gereicht
|
||||||
|
|
||||||
|
Konfiguration ueber Umgebungsvariablen (siehe bondrucker.service):
|
||||||
|
PRINT_DEVICE Geraetedatei, Standard /dev/usb/lp0
|
||||||
|
PRINT_HOST Lausch-Adresse, Standard 0.0.0.0
|
||||||
|
PRINT_PORT Lausch-Port, Standard 9100
|
||||||
|
PRINT_TOKEN gemeinsames Geheimnis; leer = ohne Anmeldung (nur im LAN!)
|
||||||
|
PRINT_MAX_BYTES Obergrenze je Auftrag, Standard 262144
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socketserver
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
VERSION = "1.0"
|
||||||
|
|
||||||
|
DEVICE = os.environ.get("PRINT_DEVICE", "/dev/usb/lp0")
|
||||||
|
HOST = os.environ.get("PRINT_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("PRINT_PORT", "9100"))
|
||||||
|
TOKEN = os.environ.get("PRINT_TOKEN", "").strip()
|
||||||
|
MAX_BYTES = int(os.environ.get("PRINT_MAX_BYTES", str(256 * 1024)))
|
||||||
|
|
||||||
|
# Zwei Auftraege duerfen sich nicht ineinander schieben - sonst kommt Buchstaben-
|
||||||
|
# salat aus dem Drucker. Ein Prozess, ein Schloss.
|
||||||
|
_print_lock = threading.Lock()
|
||||||
|
|
||||||
|
_last_error = None
|
||||||
|
_last_ok = None
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def find_device():
|
||||||
|
"""Konfiguriertes Geraet, sonst das erste vorhandene /dev/usb/lp*.
|
||||||
|
|
||||||
|
Nach dem Aus- und Wiedereinstecken des USB-Kabels kann aus lp0 ein lp1
|
||||||
|
werden. Ohne diesen Fallback druckt danach nichts mehr, bis jemand den
|
||||||
|
Dienst anfasst.
|
||||||
|
"""
|
||||||
|
if os.path.exists(DEVICE):
|
||||||
|
return DEVICE
|
||||||
|
found = sorted(glob.glob("/dev/usb/lp*"))
|
||||||
|
return found[0] if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def device_status():
|
||||||
|
dev = find_device()
|
||||||
|
status = {
|
||||||
|
"ok": False,
|
||||||
|
"version": VERSION,
|
||||||
|
"device": dev or DEVICE,
|
||||||
|
"deviceConfigured": DEVICE,
|
||||||
|
"deviceExists": dev is not None,
|
||||||
|
"writable": False,
|
||||||
|
"lastError": _last_error,
|
||||||
|
"lastPrintAt": _last_ok,
|
||||||
|
}
|
||||||
|
if dev:
|
||||||
|
status["writable"] = os.access(dev, os.W_OK)
|
||||||
|
status["ok"] = status["writable"]
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def write_to_printer(data):
|
||||||
|
"""Schreibt einen Auftrag auf den Drucker. Wirft bei Fehlern OSError."""
|
||||||
|
global _last_error, _last_ok
|
||||||
|
|
||||||
|
with _print_lock:
|
||||||
|
dev = find_device()
|
||||||
|
if dev is None:
|
||||||
|
raise OSError("Kein Druckergeraet gefunden (/dev/usb/lp*). Kabel? Strom?")
|
||||||
|
|
||||||
|
# os.open statt open(): kein Puffer dazwischen, und Fehler des Druckers
|
||||||
|
# (Papier leer, Deckel offen) kommen direkt als OSError zurueck.
|
||||||
|
fd = os.open(dev, os.O_WRONLY)
|
||||||
|
try:
|
||||||
|
written = 0
|
||||||
|
while written < len(data):
|
||||||
|
written += os.write(fd, data[written:])
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
_last_error = None
|
||||||
|
_last_ok = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def token_ok(supplied):
|
||||||
|
if not TOKEN:
|
||||||
|
return True
|
||||||
|
return hmac.compare_digest(TOKEN, (supplied or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "bondrucker/" + VERSION
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def _send(self, code, payload):
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.rstrip("/") in ("/status", "/health", ""):
|
||||||
|
st = device_status()
|
||||||
|
self._send(200 if st["ok"] else 503, st)
|
||||||
|
else:
|
||||||
|
self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
global _last_error
|
||||||
|
|
||||||
|
if self.path.rstrip("/") != "/print":
|
||||||
|
return self._send(404, {"ok": False, "error": "Unbekannter Pfad"})
|
||||||
|
|
||||||
|
if not token_ok(self.headers.get("X-Print-Token")):
|
||||||
|
log(f"[abgelehnt] falsches Token von {self.client_address[0]}")
|
||||||
|
return self._send(403, {"ok": False, "error": "Token ungueltig"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
except ValueError:
|
||||||
|
return self._send(400, {"ok": False, "error": "Content-Length fehlt"})
|
||||||
|
|
||||||
|
if length <= 0:
|
||||||
|
return self._send(400, {"ok": False, "error": "Leerer Druckauftrag"})
|
||||||
|
if length > MAX_BYTES:
|
||||||
|
return self._send(413, {"ok": False, "error": f"Auftrag groesser als {MAX_BYTES} Bytes"})
|
||||||
|
|
||||||
|
data = self.rfile.read(length)
|
||||||
|
|
||||||
|
try:
|
||||||
|
written = write_to_printer(data)
|
||||||
|
except OSError as err:
|
||||||
|
_last_error = str(err)
|
||||||
|
log(f"[fehler] {self.client_address[0]}: {err}")
|
||||||
|
return self._send(502, {"ok": False, "error": f"Drucker meldet: {err}"})
|
||||||
|
|
||||||
|
log(f"[gedruckt] {written} Bytes von {self.client_address[0]}")
|
||||||
|
self._send(200, {"ok": True, "bytes": written})
|
||||||
|
|
||||||
|
# Zugriffszeilen selbst formatieren; das Standardformat von
|
||||||
|
# BaseHTTPRequestHandler geht auf stderr und verrauscht das Journal.
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Server(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
st = device_status()
|
||||||
|
log(f"Bondrucker-Bruecke {VERSION}")
|
||||||
|
log(f" Geraet: {st['device']} (vorhanden: {st['deviceExists']}, beschreibbar: {st['writable']})")
|
||||||
|
log(f" Lauscht: {HOST}:{PORT}")
|
||||||
|
log(f" Token: {'gesetzt' if TOKEN else 'NICHT gesetzt - jeder im Netz darf drucken'}")
|
||||||
|
|
||||||
|
if not st["deviceExists"]:
|
||||||
|
log(" Hinweis: Drucker gerade nicht da. Der Dienst laeuft trotzdem und")
|
||||||
|
log(" findet das Geraet, sobald es wieder angesteckt ist.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
Server((HOST, PORT), Handler).serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
log("Beendet.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+131
-10
@@ -10,6 +10,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
|
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
|
||||||
rate: { rateAmount: 10, rateInterval: 10 },
|
rate: { rateAmount: 10, rateInterval: 10 },
|
||||||
|
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
|
||||||
|
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
|
||||||
tickHandle: null,
|
tickHandle: null,
|
||||||
syncHandle: null,
|
syncHandle: null,
|
||||||
history: { offset: 0, limit: 50, reachedEnd: false }
|
history: { offset: 0, limit: 50, reachedEnd: false }
|
||||||
@@ -63,7 +65,9 @@ function toast(message, kind = 'info') {
|
|||||||
el.textContent = message;
|
el.textContent = message;
|
||||||
el.className = 'toast show ' + kind;
|
el.className = 'toast show ' + kind;
|
||||||
clearTimeout(toastTimeout);
|
clearTimeout(toastTimeout);
|
||||||
toastTimeout = setTimeout(() => { el.className = 'toast'; }, 4000);
|
// Fehler laenger stehen lassen: die Meldung "Bon nicht gedruckt" enthaelt
|
||||||
|
// den Grund und den Hinweis auf den Nachdruck - das will gelesen werden.
|
||||||
|
toastTimeout = setTimeout(() => { el.className = 'toast'; }, kind === 'error' ? 8000 : 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(url, options = {}) {
|
async function api(url, options = {}) {
|
||||||
@@ -178,6 +182,8 @@ async function logout() {
|
|||||||
async function syncTimers() {
|
async function syncTimers() {
|
||||||
const data = await api('/api/timers');
|
const data = await api('/api/timers');
|
||||||
state.rate = data.rate;
|
state.rate = data.rate;
|
||||||
|
state.printerConfigured = !!data.printerConfigured;
|
||||||
|
state.printAutoOnStop = data.printAutoOnStop !== false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
state.timers = data.timers.map(t => ({
|
state.timers = data.timers.map(t => ({
|
||||||
@@ -261,6 +267,10 @@ function renderTimers() {
|
|||||||
${timer.isRunning
|
${timer.isRunning
|
||||||
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
|
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
|
||||||
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
|
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
|
||||||
|
${state.printerConfigured
|
||||||
|
? `<button class="btn btn-secondary btn-sm" onclick="printInterim(${timer.id})"
|
||||||
|
title="Zwischenstand drucken - der Timer läuft weiter">🖨 Bon</button>`
|
||||||
|
: ''}
|
||||||
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
|
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
|
||||||
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
|
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,21 +326,48 @@ async function resumeTimer(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwischenbon: der Kunde geht jetzt zur Kasse, der Platz bleibt aber belegt.
|
||||||
|
* Der Timer wird dabei bewusst NICHT angefasst.
|
||||||
|
*/
|
||||||
|
async function printInterim(id) {
|
||||||
|
const timer = state.timers.find(t => t.id === id);
|
||||||
|
if (!timer) return;
|
||||||
|
try {
|
||||||
|
toast('Bon wird gedruckt …', 'info');
|
||||||
|
const res = await api(`/api/timers/${id}/receipt`, { method: 'POST' });
|
||||||
|
toast(`Bon gedruckt – ${formatEuro(res.amount)} (Timer läuft weiter)`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Bon konnte nicht gedruckt werden: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stopTimer(id) {
|
async function stopTimer(id) {
|
||||||
const timer = state.timers.find(t => t.id === id);
|
const timer = state.timers.find(t => t.id === id);
|
||||||
if (!timer) return;
|
if (!timer) return;
|
||||||
|
|
||||||
const ms = currentMs(timer);
|
const ms = currentMs(timer);
|
||||||
const label = timer.customerName ? `„${timer.customerName}"` : 'Timer';
|
const label = timer.customerName ? `„${timer.customerName}"` : 'Timer';
|
||||||
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.`)) return;
|
const bonHinweis = state.printerConfigured && state.printAutoOnStop
|
||||||
|
? '\n\nDer Bon für die Kasse wird gedruckt.' : '';
|
||||||
|
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.${bonHinweis}`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
|
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
|
||||||
await syncTimers();
|
await syncTimers();
|
||||||
if (result.saved) {
|
if (!result.saved) {
|
||||||
toast(`Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`, 'success');
|
return toast(result.reason || 'Nichts gespeichert.', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const kern = `Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`;
|
||||||
|
if (result.printed) {
|
||||||
|
toast(kern + ' · Bon gedruckt', 'success');
|
||||||
|
} else if (result.printError) {
|
||||||
|
// Bewusst als Fehler und mit Nennung des Nachdrucks: der Vorgang ist
|
||||||
|
// gespeichert, es fehlt nur das Papier.
|
||||||
|
toast(`${kern}\nBon NICHT gedruckt (${result.printError}) – Nachdruck über die Historie.`, 'error');
|
||||||
} else {
|
} else {
|
||||||
toast(result.reason || 'Nichts gespeichert.', 'info');
|
toast(kern, 'success');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
|
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
|
||||||
@@ -426,6 +463,10 @@ function renderHistory(data, reset) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="history-item-right">
|
<div class="history-item-right">
|
||||||
<div class="history-item-amount">${formatEuro(s.amount)}</div>
|
<div class="history-item-amount">${formatEuro(s.amount)}</div>
|
||||||
|
${state.printerConfigured
|
||||||
|
? `<button class="btn btn-secondary btn-sm" onclick="reprintSession(${s.id})"
|
||||||
|
title="Bon noch einmal drucken">🖨</button>`
|
||||||
|
: ''}
|
||||||
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
|
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -448,6 +489,17 @@ async function loadMoreHistory() {
|
|||||||
await loadHistory(false);
|
await loadHistory(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nachdruck aus der Historie - Papier war leer, Bon verlegt, Kunde will eine Kopie. */
|
||||||
|
async function reprintSession(id) {
|
||||||
|
try {
|
||||||
|
toast('Bon wird gedruckt …', 'info');
|
||||||
|
await api(`/api/sessions/${id}/receipt`, { method: 'POST' });
|
||||||
|
toast('Bon nachgedruckt', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Nachdruck fehlgeschlagen: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSession(id) {
|
async function deleteSession(id) {
|
||||||
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
|
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -502,11 +554,20 @@ document.getElementById('filterName').addEventListener('input', (() => {
|
|||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const rate = await api('/api/settings');
|
const settings = await api('/api/settings');
|
||||||
state.rate = rate;
|
state.rate = settings;
|
||||||
document.getElementById('rateAmount').value = rate.rateAmount;
|
state.printerConfigured = !!(settings.printer && settings.printer.configured);
|
||||||
document.getElementById('rateInterval').value = rate.rateInterval;
|
state.printAutoOnStop = settings.printAutoOnStop !== false;
|
||||||
|
|
||||||
|
document.getElementById('rateAmount').value = settings.rateAmount;
|
||||||
|
document.getElementById('rateInterval').value = settings.rateInterval;
|
||||||
|
document.getElementById('receiptHeader').value = settings.receiptHeader || '';
|
||||||
|
document.getElementById('receiptFooter').value = settings.receiptFooter || '';
|
||||||
|
document.getElementById('printAutoOnStop').checked = state.printAutoOnStop;
|
||||||
|
|
||||||
updateSettingsPreview();
|
updateSettingsPreview();
|
||||||
|
renderPrinterState(settings.printer);
|
||||||
|
refreshPrinterStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
|
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -515,13 +576,17 @@ async function loadSettings() {
|
|||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
|
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
|
||||||
const rateInterval = parseInt(document.getElementById('rateInterval').value);
|
const rateInterval = parseInt(document.getElementById('rateInterval').value);
|
||||||
|
const receiptHeader = document.getElementById('receiptHeader').value;
|
||||||
|
const receiptFooter = document.getElementById('receiptFooter').value;
|
||||||
|
const printAutoOnStop = document.getElementById('printAutoOnStop').checked;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rate = await api('/api/settings', {
|
const rate = await api('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ rateAmount, rateInterval })
|
body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop })
|
||||||
});
|
});
|
||||||
state.rate = rate;
|
state.rate = rate;
|
||||||
|
state.printAutoOnStop = rate.printAutoOnStop !== false;
|
||||||
renderTimers();
|
renderTimers();
|
||||||
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
|
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
|
||||||
showTimer();
|
showTimer();
|
||||||
@@ -537,6 +602,62 @@ async function resetSettings() {
|
|||||||
await saveSettings();
|
await saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bondrucker
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Zeigt in den Einstellungen, ob und wo ein Drucker haengt. */
|
||||||
|
function renderPrinterState(info, live) {
|
||||||
|
const el = document.getElementById('printerState');
|
||||||
|
const testBtn = document.getElementById('printerTestBtn');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!info || !info.configured) {
|
||||||
|
el.className = 'printer-state off';
|
||||||
|
el.textContent = 'Kein Bondrucker eingerichtet. Einzurichten in der .env des Servers (PRINTER_HOST) – siehe deploy/bondrucker/README.md.';
|
||||||
|
if (testBtn) testBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testBtn) testBtn.disabled = false;
|
||||||
|
const ziel = `${info.host}:${info.port}`;
|
||||||
|
|
||||||
|
if (live === undefined) {
|
||||||
|
el.className = 'printer-state';
|
||||||
|
el.textContent = `Drucker eingerichtet: ${ziel} – Zustand wird geprüft …`;
|
||||||
|
} else if (live.ok) {
|
||||||
|
el.className = 'printer-state ok';
|
||||||
|
const geraet = live.printer && live.printer.device ? ` (${live.printer.device})` : '';
|
||||||
|
el.textContent = `Drucker bereit: ${ziel}${geraet}`;
|
||||||
|
} else {
|
||||||
|
el.className = 'printer-state error';
|
||||||
|
el.textContent = `Drucker nicht erreichbar: ${ziel} – ${live.error || 'unbekannter Fehler'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPrinterStatus() {
|
||||||
|
try {
|
||||||
|
const status = await api('/api/printer/status');
|
||||||
|
renderPrinterState({ configured: status.configured, host: status.host, port: status.port }, status);
|
||||||
|
} catch (e) {
|
||||||
|
renderPrinterState({ configured: state.printerConfigured }, { ok: false, error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testPrint() {
|
||||||
|
const btn = document.getElementById('printerTestBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await api('/api/printer/test', { method: 'POST' });
|
||||||
|
toast('Probebon gedruckt', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast('Probedruck fehlgeschlagen: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
refreshPrinterStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateSettingsPreview() {
|
function updateSettingsPreview() {
|
||||||
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
|
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
|
||||||
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
|
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
|
||||||
|
|||||||
+105
-4
@@ -219,7 +219,8 @@
|
|||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="number"],
|
input[type="number"],
|
||||||
input[type="date"] {
|
input[type="date"],
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1.125rem;
|
padding: 0.875rem 1.125rem;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
@@ -231,14 +232,16 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #4d35a9;
|
border-color: #4d35a9;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35);
|
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder { color: rgba(255, 255, 255, 0.35); }
|
input::placeholder,
|
||||||
|
textarea::placeholder { color: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
/* Datumsfelder im Dark Theme lesbar halten */
|
/* Datumsfelder im Dark Theme lesbar halten */
|
||||||
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
|
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
|
||||||
@@ -551,6 +554,66 @@
|
|||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Bondrucker ---------- */
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section > p {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group + .form-group { margin-top: 1.25rem; }
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row input[type="checkbox"] {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: #6d4aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row label { font-weight: 500; cursor: pointer; }
|
||||||
|
.checkbox-row small { display: block; color: rgba(255, 255, 255, 0.5); font-size: 0.8rem; margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
.printer-state {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.printer-state.ok { border-left-color: #22c55e; }
|
||||||
|
.printer-state.error { border-left-color: #ef4444; }
|
||||||
|
.printer-state.off { border-left-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.6); }
|
||||||
|
|
||||||
.settings-actions { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
.settings-actions { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
||||||
.settings-actions .btn { flex: 1; padding: 1rem; }
|
.settings-actions .btn { flex: 1; padding: 1rem; }
|
||||||
|
|
||||||
@@ -582,6 +645,8 @@
|
|||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
/* Meldungen zum Bondruck sind zweizeilig - Umbrueche sollen stehen bleiben. */
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||||
@@ -716,6 +781,42 @@
|
|||||||
<p class="preview-examples" id="previewExamples"></p>
|
<p class="preview-examples" id="previewExamples"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>🖨️ Bon für die Kasse</h3>
|
||||||
|
<p>Was auf dem Zettel steht, den der Kunde mit zur Kasse nimmt.
|
||||||
|
Betrag, Zeiten und Tarif kommen automatisch dazu.</p>
|
||||||
|
|
||||||
|
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="receiptHeader">Kopfzeilen</label>
|
||||||
|
<textarea id="receiptHeader" maxlength="240" rows="3"
|
||||||
|
placeholder="Handy-Insel Hauptstraße 12, 12345 Musterstadt Tel. 01234 / 56789"></textarea>
|
||||||
|
<small>Erste Zeile wird groß gedruckt. Eine Zeile je Zeilenumbruch, maximal 240 Zeichen.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="receiptFooter">Fußzeilen</label>
|
||||||
|
<textarea id="receiptFooter" maxlength="240" rows="2"
|
||||||
|
placeholder="Bitte an der Kasse bezahlen."></textarea>
|
||||||
|
<small>Steht unter dem Betrag, maximal 240 Zeichen.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" id="printAutoOnStop" checked>
|
||||||
|
<label for="printAutoOnStop">Bon beim Beenden automatisch drucken
|
||||||
|
<small>Aus: der Bon wird nur über die Knöpfe 🖨 gedruckt.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-actions" style="margin-top: 1.25rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" id="printerTestBtn"
|
||||||
|
onclick="testPrint()">🖨️ Probedruck</button>
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="refreshPrinterStatus()">🔄 Drucker prüfen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<button type="submit" class="btn">💾 Speichern</button>
|
<button type="submit" class="btn">💾 Speichern</button>
|
||||||
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
|
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
|
||||||
@@ -733,6 +834,6 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script src="/app.js?v=20260803-sso"></script>
|
<script src="/app.js?v=20260904-bon"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user