diff --git a/DEPLOY.md b/DEPLOY.md index 8946fd4..a464a5c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -288,6 +288,24 @@ Drei Wege führen zu einem Bon: | **✓ Beenden** | Endbeleg | ist beendet und in der Historie | | **🖨** in der Historie | Nachdruck desselben Belegs | — | +#### Strichcode + +Unter dem Betrag steht ein Strichcode mit dem Artikelnamen, den die Kasse führt +(ab Werk `techniker`). Name und An/Aus stehen in den Einstellungen, also in der +Datenbank — nicht im Code und nicht in der `.env`. + +Symbologie ist **CODE128, Zeichensatz B**. CODE39, sonst der Standard für solche +Fälle, kennt nur Großbuchstaben und könnte `techniker` gar nicht abbilden. +Zeichensatz B deckt den druckbaren ASCII-Bereich ab; alles darüber — Umlaute +zumal — weist `PUT /api/settings` ab, statt es still zu verschlucken. + +Die Modulbreite wählt `escpos.js` selbst: so breit, wie die Papierbreite es +zulässt (Ruhezonen eingerechnet), denn breitere Balken liest ein Handscanner +zuverlässiger. Daraus folgt die Längengrenze — bei 42 Zeichen je Zeile sind es +17 Zeichen, bei 32 noch 12. Passt der Name nicht, druckt `receipt.js` ihn nur +in Klarschrift: ein abgeschnittener Code würde ohnehin von keinem Scanner +gelesen. + **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 @@ -453,7 +471,7 @@ Alle Endpunkte außer `/health`, `/api/login`, `/api/auth/status` und | `POST` | `/api/timers/:id/receipt` | Zwischenbon drucken, Timer bleibt unangetastet | | `DELETE` | `/api/timers/:id` | verwerfen, **nichts** speichern | | `GET` | `/api/settings` | Tarif, Bon-Texte und Druckerzustand lesen | -| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop}` | +| `PUT` | `/api/settings` | `{rateAmount, rateInterval}` sowie optional `{receiptHeader, receiptFooter, printAutoOnStop, receiptBarcodeEnabled, receiptBarcodeArticle}` | | `GET` | `/api/printer/status` | Erreichbarkeit und Zustand des Druckers | | `POST` | `/api/printer/test` | Probedruck | | `POST` | `/api/printer/provision` | `{host, port, username, password}` → richtet den Pi ein und übernimmt ihn. Antwort enthält das Schrittprotokoll, nie die Zugangsdaten | diff --git a/README.md b/README.md index b800cb6..4a347d4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,23 @@ Zwei Wege, je nachdem, wie der Kunde geht: Zettel zeigt den Stand von jetzt und ist als *Zwischenstand* gekennzeichnet; der Timer läuft unverändert weiter. +### Strichcode für die Kasse + +Unter dem Betrag steht ein Strichcode. Die Kasse scannt ihn und hat den Artikel +sofort auf dem Bildschirm — niemand muss ihn in einer Liste suchen. Darunter +druckt der Drucker denselben Text noch einmal in Klarschrift, für den Fall, dass +der Scanner nicht anspringt oder der Zettel geknickt ist. + +Welcher Artikel das ist, steht in den **Einstellungen** unter *Artikel in der +Kasse*; ab Werk `techniker`. Wird die Leistung in der Kasse umbenannt, wird der +Name hier geändert — ohne dass jemand an die App muss. Der ganze Block lässt +sich mit *Strichcode für die Kasse drucken* auch abschalten. + +Zwei Grenzen setzt das Papier: Der Name darf **keine Umlaute und keine +Sonderzeichen** enthalten (ein Strichcode kann sie nicht abbilden) und nicht +länger sein, als quer auf die Rolle passt — bei 80 mm sind das 17 Zeichen. Beides +sagt die App beim Speichern, statt später einen unlesbaren Code zu drucken. + ### Wenn nichts aus dem Drucker kommt Der Vorgang ist trotzdem gespeichert — die App sagt in dem Fall ausdrücklich, @@ -90,6 +107,9 @@ Darunter steht, was auf dem Bon gedruckt wird: bezahlen." * **Bon beim Beenden automatisch drucken** — ausgeschaltet kommt ein Bon nur noch über die 🖨-Knöpfe. +* **Strichcode für die Kasse drucken** — siehe unten. Ausgeschaltet bleibt der + Bon genau wie vorher. +* **Artikel in der Kasse** — der Name, der im Strichcode steckt. * **Probedruck** — druckt einen Beispielbon. Beantwortet die Frage, ob der Drucker überhaupt erreichbar ist; darüber steht sein aktueller Zustand. diff --git a/backend/escpos.js b/backend/escpos.js index 4897a45..ec9e750 100644 --- a/backend/escpos.js +++ b/backend/escpos.js @@ -20,6 +20,24 @@ const GS = 0x1d; // von Node plus diese eine Sonderbehandlung. const CODEPAGE_WPC1252 = 16; +// Breite eines Zeichens in Schriftart A, in Druckpunkten. Daraus ergibt sich, +// wie viel Platz ein Strichcode quer ueber das Papier hat. +const ZEICHENBREITE = 12; + +// Ruhezone links und rechts vom Strichcode, in Modulen. Ohne diesen Abstand +// findet ein Scanner den Anfang des Codes nicht zuverlaessig. +const RUHEZONE = 10; + +/** + * Wie viele Zeichen ein CODE128-Strichcode auf dieser Papierbreite noch + * verkraeftet. Bei der schmalsten noch lesbaren Modulbreite (2 Punkte) + * gerechnet, also der guenstigste Fall. + */ +function barcodeMaxLength(width = 42) { + const module = Math.floor((width * ZEICHENBREITE) / 2) - 35 - 2 * RUHEZONE; + return Math.max(0, Math.floor(module / 11)); +} + /** Zeichen, die WPC1252 nicht kennt, auf etwas Druckbares abbilden. */ const ERSATZ = { '€': '\x80', @@ -131,6 +149,52 @@ class Bon { return this; } + /** + * Strichcode in CODE128, Zeichensatz B. + * + * CODE128 statt des sonst ueblichen CODE39, weil CODE39 nur + * Grossbuchstaben kennt - ein Artikel namens "techniker" liesse sich damit + * nicht abbilden. Zeichensatz B deckt den druckbaren ASCII-Bereich ab und + * wird von jedem Handscanner gelesen. + * + * Die Modulbreite - die Breite des schmalsten Balkens - wird so gross + * gewaehlt, wie das Papier es zulaesst. Breitere Balken liest ein Scanner + * zuverlaessiger, gerade auf Thermopapier, das schon einen Tag in einer + * Hosentasche hinter sich hat. + * + * @param {string} text Nutzdaten, druckbares ASCII + * @param {object} [o] + * @param {number} [o.hoehe] Hoehe in Druckpunkten (8 Punkte = 1 mm) + * @param {boolean} [o.klarschrift] Nutzdaten zusaetzlich lesbar darunter + */ + barcode(text, { hoehe = 64, klarschrift = true } = {}) { + // Was der Zeichensatz nicht kann, wuerde der Drucker stillschweigend + // verschlucken und einen Code ausgeben, der etwas anderes bedeutet. + const daten = String(text == null ? '' : text).replace(/[^\x20-\x7e]/g, ''); + if (!daten) return this; + + // Ein CODE128-Zeichen belegt 11 Module, dazu kommen Start, Pruefzeichen + // und Stopp (zusammen 35) sowie die Ruhezone auf beiden Seiten. + const module = 11 * daten.length + 35 + 2 * RUHEZONE; + const modul = Math.max(2, Math.min(4, Math.floor((this.width * ZEICHENBREITE) / module))); + + this.raw(GS, 0x68, Math.max(1, Math.min(255, hoehe))); // GS h Hoehe + this.raw(GS, 0x77, modul); // GS w Modulbreite + this.raw(GS, 0x66, 0); // GS f Schrift der Klarschrift + this.raw(GS, 0x48, klarschrift ? 2 : 0); // GS H Klarschrift darunter + + // "{B" waehlt den Zeichensatz. Eine geschweifte Klammer in den Daten + // muss deshalb verdoppelt werden, sonst liest der Drucker sie als + // Umschaltbefehl und der Code enthaelt am Ende etwas anderes. + const nutz = Buffer.from('{B' + daten.split('{').join('{{'), 'latin1'); + + // Form 2 von GS k: die Laenge steht als Byte davor, statt die Daten mit + // einem Nullbyte abzuschliessen. Nur so darf jedes Zeichen vorkommen. + this.raw(GS, 0x6b, 73, nutz.length); + this.parts.push(nutz); + return this; + } + feed(n = 1) { return this.raw(ESC, 0x64, Math.max(0, Math.min(255, n))); } @@ -149,4 +213,4 @@ class Bon { } } -module.exports = { Bon, encodeText }; +module.exports = { Bon, encodeText, barcodeMaxLength }; diff --git a/backend/receipt.js b/backend/receipt.js index f3e16ba..d6fe6ff 100644 --- a/backend/receipt.js +++ b/backend/receipt.js @@ -10,7 +10,7 @@ * dem Zielhost gebaut wurde. */ -const { Bon } = require('./escpos'); +const { Bon, barcodeMaxLength } = require('./escpos'); const p2 = n => String(n).padStart(2, '0'); @@ -59,6 +59,7 @@ function unitCount(ms, rateInterval) { * @param {boolean} data.stillRunning * @param {string} data.header Kopfzeilen, durch \n getrennt * @param {string} data.footer Fusszeilen, durch \n getrennt + * @param {string} data.barcode Artikelname fuer den Strichcode; leer = keiner * @param {number} data.width Zeichen je Zeile * @returns {Buffer} */ @@ -117,6 +118,29 @@ function buildReceipt(data) { bon.line(); bon.hr(); + // --- Strichcode fuer die Kasse ------------------------------------------ + // Der Kassierer soll den Artikel nicht in einer Liste suchen muessen: der + // Code enthaelt genau den Namen, unter dem die Kasse die Leistung fuehrt. + // Die Klarschrift darunter druckt der Drucker selbst mit - sie rettet den + // Vorgang, wenn der Scanner streikt oder das Papier geknickt ist. + if (data.barcode) { + bon.line(); + bon.align('center'); + bon.line('Artikel für die Kasse'); + if (data.barcode.length <= barcodeMaxLength(width)) { + bon.barcode(data.barcode); + } else { + // Breiter als das Papier. Ein abgeschnittener Code wird von keinem + // Scanner gelesen, also lieber gar keiner: der Name allein reicht + // zum Eintippen. Passieren kann das nur, wenn die Papierbreite + // nachtraeglich kleiner gestellt wurde. + bon.bold(true).line(data.barcode).bold(false); + } + bon.align('left'); + bon.line(); + bon.hr(); + } + // --- Fuss --------------------------------------------------------------- bon.align('center'); if (interim) { @@ -140,12 +164,40 @@ function buildReceipt(data) { return bon.build(); } +/** + * Strichcodes aus der Bytefolge herausloesen und durch ihren Inhalt ersetzen. + * + * Eigene Schleife statt eines Ausdrucks, weil bei "GS k" die Laenge der Daten + * in einem Byte MITTEN in der Sequenz steht - wie weit zu springen ist, weiss + * man also erst beim Lesen. Genau das kann ein regulaerer Ausdruck nicht. + */ +function stripBarcode(s) { + let aus = ''; + let i = 0; + while (i < s.length) { + if (s.charCodeAt(i) === 0x1d && s[i + 1] === 'k' && + s.charCodeAt(i + 2) >= 65 && s.charCodeAt(i + 2) <= 73) { + const laenge = s.charCodeAt(i + 3); + const daten = s.substr(i + 4, laenge) + .replace(/^\{[ABC]/, '') // Wahl des Zeichensatzes + .split('{{').join('{'); // verdoppelte Klammer zuruecknehmen + aus += `[Strichcode: ${daten}]\n`; + i += 4 + laenge; + continue; + } + aus += s[i]; + i++; + } + return aus; +} + /** Bon als reiner Text - fuer Vorschau, Tests und das Journal. */ function toPlainText(buffer) { - return buffer.toString('latin1') + return stripBarcode(buffer.toString('latin1')) .replace(/\x1b@/g, '') // Zuruecksetzen .replace(/\x1b[tadEG!-][\s\S]/g, '') // ESC + Kennbuchstabe + 1 Byte .replace(/\x1d![\s\S]/g, '') // GS ! Zeichengroesse + .replace(/\x1d[hwHf][\s\S]/g, '') // GS h/w/H/f Masse des Strichcodes .replace(/\x1dV[\s\S]{2}/g, '') // GS V Schnitt .replace(/\x80/g, 'EUR') .replace(/\n{3,}/g, '\n\n') diff --git a/backend/server.js b/backend/server.js index e346bca..52b0816 100644 --- a/backend/server.js +++ b/backend/server.js @@ -18,6 +18,7 @@ require('dotenv').config(); const oidc = require('./oidc'); const printer = require('./printer'); const { buildReceipt } = require('./receipt'); +const { barcodeMaxLength } = require('./escpos'); const { provision } = require('./provision'); const app = express(); @@ -152,6 +153,12 @@ async function initDatabase() { ['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')`); + // Strichcode fuer die Kasse: der Kassierer soll den Artikel scannen + // koennen, statt ihn zu suchen. Der Name steht hier und nicht im Code, + // weil er sich mit dem Artikelstamm der Kasse aendern kann. + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_enabled','1')`); + await dbRun(`INSERT OR IGNORE INTO settings (key,value) VALUES ('receipt_barcode_article','techniker')`); + // Druckerzugang: die .env liefert nur die ERSTEN Werte. Danach lebt die // Konfiguration in der Datenbank, damit die Oberflaeche einen neuen Pi // einrichten kann, ohne dass jemand auf den Server muss. @@ -213,15 +220,19 @@ async function refreshPrinterConfig() { return cfg; } -/** Bon-Einstellungen: Kopf-/Fusstext und ob beim Beenden automatisch gedruckt wird. */ +/** Bon-Einstellungen: Kopf-/Fusstext, Strichcode und der Druck beim Beenden. */ async function getPrintSettings() { const rows = await dbAll( - `SELECT key, value FROM settings WHERE key IN ('receipt_header','receipt_footer','print_auto_on_stop')`); + `SELECT key, value FROM settings WHERE key IN + ('receipt_header','receipt_footer','print_auto_on_stop', + 'receipt_barcode_enabled','receipt_barcode_article')`); 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' + printAutoOnStop: map.print_auto_on_stop !== '0', + receiptBarcodeEnabled: map.receipt_barcode_enabled !== '0', + receiptBarcodeArticle: map.receipt_barcode_article || '' }; } @@ -277,6 +288,7 @@ async function printReceipt(vorgang) { ...vorgang, header: texte.receiptHeader, footer: texte.receiptFooter, + barcode: texte.receiptBarcodeEnabled ? texte.receiptBarcodeArticle : '', width: printer.width() }); const res = await printer.print(daten); @@ -673,6 +685,29 @@ app.put('/api/settings', requireAuth, wrap(async (req, res) => { if (typeof b.printAutoOnStop === 'boolean') { await setSetting('print_auto_on_stop', b.printAutoOnStop ? '1' : '0'); } + if (typeof b.receiptBarcodeEnabled === 'boolean') { + await setSetting('receipt_barcode_enabled', b.receiptBarcodeEnabled ? '1' : '0'); + } + if (typeof b.receiptBarcodeArticle === 'string') { + const artikel = b.receiptBarcodeArticle.trim(); + // Ein Strichcode kann nur den druckbaren ASCII-Bereich darstellen. Ein + // Umlaut wuerde stillschweigend verschwinden - dann scannt die Kasse + // spaeter einen Artikel, den es so nicht gibt. Lieber hier sagen, warum. + if (/[^\x20-\x7e]/.test(artikel)) { + return res.status(400).json({ + error: 'Der Artikelname darf keine Umlaute oder Sonderzeichen enthalten - mehr kann ein Strichcode nicht abbilden.' + }); + } + // Zu lang heisst: der Code waere breiter als das Papier und damit + // abgeschnitten. Ein abgeschnittener Code wird gar nicht erst gelesen. + const max = barcodeMaxLength(printer.width()); + if (artikel.length > max) { + return res.status(400).json({ + error: `Der Artikelname passt nicht auf die Papierbreite: höchstens ${max} Zeichen.` + }); + } + await setSetting('receipt_barcode_article', artikel); + } // Druckerzugang von Hand pflegen - fuer einen Pi, der nicht ueber die // Einrichtung in dieser Oberflaeche aufgesetzt wurde. diff --git a/frontend/app.js b/frontend/app.js index 0d1a2a5..19166a5 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -569,6 +569,9 @@ async function loadSettings() { document.getElementById('receiptHeader').value = settings.receiptHeader || ''; document.getElementById('receiptFooter').value = settings.receiptFooter || ''; document.getElementById('printAutoOnStop').checked = state.printAutoOnStop; + document.getElementById('receiptBarcodeEnabled').checked = settings.receiptBarcodeEnabled !== false; + document.getElementById('receiptBarcodeArticle').value = settings.receiptBarcodeArticle || ''; + toggleBarcodeField(); updateSettingsPreview(); renderPrinterState(settings.printer); @@ -584,11 +587,16 @@ async function saveSettings() { const receiptHeader = document.getElementById('receiptHeader').value; const receiptFooter = document.getElementById('receiptFooter').value; const printAutoOnStop = document.getElementById('printAutoOnStop').checked; + const receiptBarcodeEnabled = document.getElementById('receiptBarcodeEnabled').checked; + const receiptBarcodeArticle = document.getElementById('receiptBarcodeArticle').value; try { const rate = await api('/api/settings', { method: 'PUT', - body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop }) + body: JSON.stringify({ + rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop, + receiptBarcodeEnabled, receiptBarcodeArticle + }) }); state.rate = rate; state.printAutoOnStop = rate.printAutoOnStop !== false; @@ -600,6 +608,13 @@ async function saveSettings() { } } +/** Der Artikelname interessiert nur, solange der Code auch gedruckt wird. */ +function toggleBarcodeField() { + const an = document.getElementById('receiptBarcodeEnabled').checked; + document.getElementById('barcodeArticleGroup').style.opacity = an ? '1' : '0.45'; + document.getElementById('receiptBarcodeArticle').disabled = !an; +} + async function resetSettings() { if (!confirm('Tarif auf Standard zurücksetzen (€10 pro 10 Minuten)?')) return; document.getElementById('rateAmount').value = 10; diff --git a/frontend/index.html b/frontend/index.html index 538cf36..28af6a0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -877,6 +877,22 @@ +