feat(booth): payment receipt / exit voucher — transparency slip + CP852 fixes

After a completed payment the customer always gets a transparency record:
entry time, payment time, duration parked, amount + tender. One shared
ESC/POS renderer (renderReceipt + ReceiptData in @parking/devices), two
modes: VOUCHER = those figures PLUS the scannable Code128 barcode and an
emphasised walk-back-grace line, so the one slip both proves payment and
self-exits at a distant exit reader (replaced the old barcode-only voucher);
STANDALONE = detail-only, auto-printed at payment when no voucher is issued.
Figures fold from the SIGNED ledger (latest payment event); printed on the
booth printer (failover to dispenser). Best-effort: a printer fault never
blocks the exit that already happened — the modal shows a note and offers
"Reprint receipt".

Server: booth-print.ts printPaymentReceipt() + receiptFigures(); routes
POST /api/voucher (voucher) + new POST /api/receipt (standalone/reprint).
Both ESC/POS drivers gained printReceipt(). Web: BoothPayModal auto-prints
after a non-voucher payment + reprint button; api.ts printReceipt().

CP852 fixes found on a real printout: (1) uppercase Ë was mapped to 0xEB
(that's ű) — correct byte is 0xD3; (2) Intl.NumberFormat injects a NO-BREAK
SPACE (U+00A0/U+202F) that isn't in CP852 and printed as "?" — line() now
normalises it to a plain space ("1000 Lekë"); (3) grace line wrapped
mid-word — split into two short lines.

Full build green; both receipt modes render-verified; routes live.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 20:46:38 +02:00
parent 9c9f777784
commit d71ba82999
13 changed files with 376 additions and 29 deletions
+8
View File
@@ -868,3 +868,11 @@ The owner linked a Claude Design project (`019ddfee-…`, "TRM — Tracking & Ra
## [2026-06-18] fix | Cashino printer — ping-only driver (no false status) + Albanian device-role wording
Two device-feedback issues. (1) **Cashino 80mm printer reported wrong status.** It was configured on the `rongta` driver, whose `readStatus()` scrapes the Rongta board's `/prn_stat.htm` status page — which the Cashino does NOT serve. Result: a bogus `degraded`/page-error verdict while the printer was actually online (it printed fine; `healthCheck` TCP-ping passed). Root cause: the Cashino is an ESC/POS PRINT clone but has no trustworthy STATUS mechanism. Fix: extracted the shared ESC/POS rendering + transport (renderTicket/renderReport/renderSubscriptionCard/sendRaw/probe + CP852 map + code128/qrCode) from `printer-rongta.ts` into a new `drivers/printer-escpos.ts`; added a dedicated `cashino` driver that reuses that print path but is deliberately **NOT** `MonitorableDevice` (no readStatus). So `isMonitorable()` is false and the device monitor falls back to the generic `healthCheck()` — a plain TCP reachability ping of the print socket: reachable→ready, unreachable→offline, never a guessed paper/cover state. Rongta driver unchanged (still scrapes its page, still monitorable). Registered `cashinoDriver`; re-exported from the package. Switched the live entry-dispenser printer at **10.0.10.9** from `rongta`→`cashino` in the DB (backed up incl. WAL: apps/server/parking.sqlite*.bak-cashino-*); 10.0.10.10 (booth Rongta) left as-is. Verified at runtime: cashino registered, isMonitorable=false, no readStatus, healthCheck→offline on unreachable; live /api/devices/status → both printers `ready` (lane via ping, booth via page). (2) **Albanian device-role chip wording was wrong.** The footer label is `"{category} {role}"`; the role suffixes read badly: access `mixed`="i përzier" gave `Barriera i përzier` ("Barrier mixed" — wrong word + wrong gender; `mixed` actually means a barrier spanning >1 direction) → now `hyrje/dalje` (entry/exit). printer `lane`="korsia" gave `Printer korsia` ("Printer the-lane") → now `në korsi` (at the lane); `booth`="kabina" (`Printer kabina`) → `në kabinë` (at the booth). English tidied to match: mixed→"entry/exit", lane→"at lane", booth→"at booth". i18n catalog parity green. Updated [[printer-status-monitoring]]. No schema/event-chain change.
## [2026-06-18] feat | Payment receipt — transparency slip (entry/paid/duration/amount), voucher or standalone
After a completed payment the customer now always gets a transparency record: ENTRY time, PAYMENT time, DURATION parked, AMOUNT + tender. One shared ESC/POS renderer (`renderReceipt` + `ReceiptData` in @parking/devices), two modes: (a) VOUCHER mode = those figures PLUS the scannable Code128 barcode and an emphasised walk-back-grace line ("Dilni brenda N min — skanoni këtë biletë në dalje") so the one slip both proves payment and self-exits at a distant exit reader — this REPLACED the old barcode-only voucher (printExitVoucher → printPaymentReceipt); (b) STANDALONE mode = detail-only (no barcode), AUTO-printed at payment when the voucher checkbox is OFF (booth at the exit). Figures are folded from the SIGNED ledger (latest payment event), printed on the booth printer (failover to dispenser). Money via Intl minor-units (no float); duration = whole minutes (mirrors UI formatDuration); timestamps use the host-local clock (appliance = site time; distinct from the tariff's frozen tz, which governs PRICING reproducibility not display). Server: booth-print.ts `printPaymentReceipt(db,id,{voucher},log)` + `receiptFigures()`; routes `POST /api/voucher` (voucher) and new `POST /api/receipt` (standalone/reprint — requires paid, allows already-exited so reprint works). Both ESC/POS drivers (rongta + cashino) gained `printReceipt` (PrinterDevice interface). Frontend: BoothPayModal auto-prints the standalone receipt after a non-voucher payment (BEST-EFFORT — a printer fault must not block the exit that already happened; shows a note + a "Reprint receipt" button in the done phase, also for slip jams / later asks); api.ts `printReceipt()`. Decision (with user): auto-print on payment (not on-demand-only / not a new config toggle), with reprint fallback. i18n: receiptPrintFailed/receiptReprinted/reprintReceipt/reprinting (sq+en, parity green). Verified: full build green; renderReceipt output inspected in both modes (correct figures, barcode, grace line, Albanian); /api/receipt + /api/voucher live (404 on unknown id — route+validation reached, no physical print fired on the real booth printer). Updated [[booth-exit-flow]]. No schema/event-chain change.
## [2026-06-18] fix | Receipt/voucher misprint — CP852 `Ë` byte + Intl NBSP (found on a real printout)
A printed exit voucher (photo from the booth) surfaced three glitches in the new payment receipt, all fixed in `printer-escpos.ts`. (1) **Title garbled**: uppercase `Ë` was mapped to CP852 `0xEB` — wrong (that's `ű`); the correct byte is `0xD3` (U+00CB). `BILETË DALJE`/`FATURË PAGESE` now print correctly (lowercase `ë`=0x89 was always fine). (2) **`1000?Lekë`**: `Intl.NumberFormat("sq-AL", currency:"ALL")` separates amount from currency with a NO-BREAK SPACE (U+00A0; some locales U+202F narrow NBSP), which isn't in CP852 → printed as `?`. `line()` now normalises U+00A0/U+202F → plain space before encoding, so any Intl-formatted value on a ticket is safe, not just money. (3) **Grace line wrapped mid-word** ("…në dali / 8."): split `graceLine` into two short lines (`graceLines`) that each fit 80mm, emitted as two centered line() calls. Re-rendered & byte-verified: 0xD3 present, no 0x3f (`?`) byte, two clean grace lines, `1000 Lekë`. Full build green. Documented the CP852 gotchas in [[rongta-printer]]. NB: verify CP852 bytes against Unicode.org CP852.TXT, never guess. No schema/event-chain change.