ticket: site metadata header + scannable Albanian ticket; widen barcode

- site_config gains optional park identity (park_name, operator_name, nius,
  address, phone, email); additive Drizzle migration 0001. GET/PUT
  /api/site-config read/write the full config (PUT partial patch, admin only);
  SiteSettings + SetupWizard expose the fields.
- renderTicket() prints an Albanian header sourced from site_config, the
  all-numeric 13-digit ticket id (12 random + Luhn) as Code128, large digits,
  and a lost-ticket footer. CP852 codepage so ë/ç render.
- Widen the Code128 module width 2->3 and height 80->100 dots so the
  short-range "Simple" QR/barcode reader decodes reliably (was barely reading
  at module width 2 on the 80mm head).

See wiki/concepts/site-metadata.md and ticket-encoding.md.
This commit is contained in:
2026-06-17 12:17:21 +02:00
parent 1efa77bf56
commit 727c62da90
20 changed files with 1596 additions and 155 deletions
+78
View File
@@ -0,0 +1,78 @@
---
type: concept
tags: [parking, domain, config, devices]
sources: []
updated: 2026-06-17
status: settled
---
# Site Metadata (Park Identity)
Optional, admin-set identity/metadata for the park itself, beyond the operational
`capacity` knob. Feeds the ticket/receipt header and admin display. All fields are
**optional** — the lot runs fine with none set (the ticket falls back to a generic
`PARKING` header).
## Where it lives
A single-row extension of the existing **`site_config`** table (`id` always 1) — the
established home for site-wide knobs ([[capacity-occupancy]]). **Not** a new table:
park identity is one-per-site, same cardinality as capacity, so it shares the row.
| Column | Purpose |
| --- | --- |
| `park_name` | Display name on the ticket header / UI (e.g. "Acme Parking"). |
| `operator_name` | Legal entity operating the lot — for receipts; may differ from the display name. |
| `nius` | **NIUS** — Albanian tax/identification number, printed on the receipt when set (commonly required). |
| `address` | Free-text postal address (multi-line allowed; printed line-by-line). |
| `phone` | Contact phone — also the ticket "Keni humbur biletën?" footer. |
| `email` | Contact email (stored; not yet printed). |
All are **nullable `text`**. Added in migration `0001` (additive `ADD COLUMN`, no
data loss). A **metadata change is not a schema change for the ticket id**, but
adding these *fields* IS a schema change — done via a Drizzle migration.
> **Field history.** The first cut (2026-06-17) had `vat_number` + `registration_number`.
> Renamed/trimmed the same day to a single `nius` column (Albanian deployments call the
> tax id NIUS; registration number dropped as unused). Migration `0001` was regenerated
> in place — it had not shipped beyond the dev DB, so there is no migration debt.
## Read / write path
- **API**: `GET /api/site-config` returns capacity + every metadata field (null when
unset). `PUT /api/site-config` (**admin only**) accepts a **partial** body — only the
fields present are updated; blank string → null (clears). `apps/server/src/routes/site.ts`.
- **UI**: `apps/web/src/SiteSettings.tsx` — admin edits capacity + the metadata fields
in one form (`saveSiteConfig`).
- **API client**: `SiteConfig` type + `fetchSiteConfig` / `saveSiteConfig` in `apps/web/src/api.ts`.
## On the ticket
`renderTicket()` ([[rongta-printer]]) prints a header from a `TicketHeader` (the metadata):
park name large (or `PARKING` if unset), then operator / `NIUS: <n>` / address lines
that are present; a `Keni humbur biletën? <phone>` footer if a phone is set. The entry
flow (`#ticketHeader()` in `apps/server/src/entry-flow.ts`) reads `site_config` per print.
See [[ticket-encoding]].
## Localisation (Albanian)
The ticket prints in **Albanian** for now. Strings are centralised in a `STR` table in
[[rongta-printer]] (`Printuar më:`, `Keni humbur biletën?`, `NIUS:`) so a real i18n layer
(per-locale tables + a `t()` helper, covering the web UI too) can replace them later
without touching the render functions — that broader site translation is the next step.
**Codepage (resolved 2026-06-17).** Albanian text needs `ë`/`ç`, which ASCII can't carry.
The driver now selects **CP852 (Latin-2)** via `ESC t 18` in each print preamble and
`line()` encodes text to CP852 (with an ASCII transliteration fallback for anything
unmapped, and `?` as a last resort — never a wrong glyph). Verified at byte level: `ë` →
`0x89` in "Printuar më" / "biletën" / a sample address.
## Open
- **Receipt vs entry ticket** — the same header is used for the entry ticket today;
a paid receipt may want more (fee, tariff version, paid-at). Design with [[tariff]].
- **Email** is stored but not yet printed (no use decided).
- **Full i18n** — only the ticket is Albanian so far; the web UI is still English. A
proper locale system (and admin language choice) is the broader task this seeds.
- **CP852 coverage** — the map covers the common Albanian/Latin-2 letters; extend if a
park name/address uses a glyph outside it (currently transliterated to ASCII).
+33 -5
View File
@@ -21,14 +21,35 @@ must have:
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
someone else's cheaper session). Sequential **physical** stock numbering is a separate
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
- **All-numeric** (as-built 2026-06-17) — so ANY legacy 1D barcode scanner reads it and an operator
can hand-key it. Random (not sequential), so "all-numeric" does not weaken the unguessable
property. Format: **13 digits = 12 cryptographically-random digits + 1 Luhn check digit**
(10^12 space → negligible collisions at lot scale; the Luhn digit lets manual entry reject a typo
rather than fail as "session not found"). `newTicketId()` in `apps/server/src/entry-flow.ts`;
validate with `validateTicketCode()` (gate MANUAL entry only — a scanned/looked-up id already in
the ledger is authoritative regardless of format).
- **Format is a property of minting, not the schema** — `identity` / `sessions.id` are free-form
`text`, so changing the id format is a code change with **no migration**. Legacy `T-<uuid>` ids
(pre-2026-06-17) remain valid keys and coexist with numeric ones.
- **Single logical session** — scanning it at the pay station finds the open session; after payment
it's the proof-of-paid the exit checks.
## Encoding: QR (preferred) — printed by the booth dispenser
## Encoding: Code128 numeric barcode — printed by the booth dispenser
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
camera/imager to read.
- The [[rongta-printer]] prints the ticket id as a **1D Code128 barcode** (the all-numeric code),
with the **same code in large human-readable digits below it**, then the entry time. Code128 over
QR for the primary symbology because the booth's reader hardware is unknown and a legacy 1D laser
scanner is the lowest common denominator — and the printed digits mean total reader failure still
leaves a hand-keyable code. A **QR for phone/imager scanning may be added later behind an admin
toggle** (deferred — see Open).
> **As-built (2026-06-17).** `renderTicket()` in [[rongta-printer]]
> (`packages/devices/src/drivers/printer-rongta.ts`) emits the Code128 via ESC/POS `GS k` (code set
> B) — **rendered by the printer firmware**, so there is no image-rendering step and no new
> dependency (keeps the MIT/Apache/BSD constraint). Resilience rationale: the booth's reader is
> uncertain, so the id is carried in two independently-readable forms (1D barcode / printed digits).
> The "operator scans with a phone" path reuses the
> existing dispatch flow ([[entry-exit-readers]]) and is tracked separately (not yet built).
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
payment ([[tariff]], pay-on-foot) → appends `payment`.
@@ -51,7 +72,14 @@ isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-s
## Open
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
- Primary symbology **decided**: Code128 set B over the all-numeric id (as-built above). Still open:
what *else* prints (site name, tariff summary, help number).
- **Optional QR (deferred)** — an admin toggle to ALSO print a QR for phone/imager users. The
`code128()`/`qrCode()` ESC/POS helpers were prototyped 2026-06-16; QR was dropped 2026-06-17 in
favor of "1D barcode + hand-keyable numeric code" because the booth's reader hardware is unknown.
Revisit when mobile scanning is wanted.
- **Phone-scan fallback** (operator scans a ticket with a phone when a reader is down) — designed
but not built: an authenticated route feeding the same dispatcher + a minimal mobile scan UI.
- Scanner hardware (imager model; same unit at pay station and exit?).
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
amount).