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
@@ -0,0 +1,6 @@
ALTER TABLE `site_config` ADD `park_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `operator_name` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `nius` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `address` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `site_config` ADD `email` text;
+797
View File
@@ -0,0 +1,797 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2cfc13fa-43fc-4f89-8438-7b9bcaf7ea3b",
"prevId": "a6d81d46-c4a4-4ee7-8565-ec012bbe0252",
"tables": {
"blocklist": {
"name": "blocklist",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"active": {
"name": "active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"added_by": {
"name": "added_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"device_events": {
"name": "device_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"devices": {
"name": "devices",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"driver_id": {
"name": "driver_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ledger_events": {
"name": "ledger_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"index": {
"name": "index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"occurred_at": {
"name": "occurred_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prev_hash": {
"name": "prev_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"signature": {
"name": "signature",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key_id": {
"name": "key_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"ledger_events_index_unique": {
"name": "ledger_events_index_unique",
"columns": [
"index"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_credentials": {
"name": "permit_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permit_plates": {
"name": "permit_plates",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"plate": {
"name": "plate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"permits": {
"name": "permits",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"holder_name": {
"name": "holder_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"contact": {
"name": "contact",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_concurrent": {
"name": "max_concurrent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
},
"valid_from": {
"name": "valid_from",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"valid_to": {
"name": "valid_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"permit_id": {
"name": "permit_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entered_at": {
"name": "entered_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"exited_at": {
"name": "exited_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"last_event_index": {
"name": "last_event_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"setup_state": {
"name": "setup_state",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"site_config": {
"name": "site_config",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"capacity": {
"name": "capacity",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"park_name": {
"name": "park_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"operator_name": {
"name": "operator_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"nius": {
"name": "nius",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"snapshots": {
"name": "snapshots",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"direction": {
"name": "direction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"identity": {
"name": "identity",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bytes": {
"name": "bytes",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"captured_at": {
"name": "captured_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariff_versions": {
"name": "tariff_versions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"tariff_id": {
"name": "tariff_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"effective_from": {
"name": "effective_from",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"structure": {
"name": "structure",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tariffs": {
"name": "tariffs",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'site'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1781632874398,
"tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1781682176094,
"tag": "0001_neat_slipstream",
"breakpoints": true
}
]
}
+17
View File
@@ -131,9 +131,26 @@ export const setupState = sqliteTable("setup_state", {
// Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
// Park identity/metadata (all optional) lives here too — display name, the legal
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
// header (park name + NIUS are commonly required on an Albanian parking receipt)
// and admin display. All nullable: the lot runs fine with none set.
// See wiki/concepts/site-metadata.md.
export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
parkName: text("park_name"),
/** Legal entity operating the lot, for receipts (may differ from parkName). */
operatorName: text("operator_name"),
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
nius: text("nius"),
/** Free-text postal address (multi-line allowed). */
address: text("address"),
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
phone: text("phone"),
/** Contact email. */
email: text("email"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
+108 -11
View File
@@ -40,15 +40,91 @@ const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
// Select code page 852 (Latin-2) for the character set: ESC t n, n=18 (0x12).
// CP852 carries the Albanian letters we print (ë, ç, …); without it the printer
// would interpret our high bytes as CP437 glyphs. Sent in every print's INIT
// preamble. See wiki/concepts/site-metadata.md (i18n / codepage).
const SELECT_CP852 = Buffer.from([ESC, 0x74, 0x12]);
// Minimal Unicode → CP852 byte map for the characters Albanian text actually uses
// beyond ASCII. Anything not listed is transliterated to an ASCII fallback (below)
// so we never emit a byte that renders as the wrong glyph. Extend as needed.
const CP852: Record<string, number> = {
ë: 0x89, Ë: 0xeb,
ç: 0x87, Ç: 0x80,
// common Latin-2 extras that may appear in a park name/address:
ä: 0x84, ö: 0x94, ü: 0x81, é: 0x82, á: 0xa0, í: 0xa1, ó: 0xa2, ú: 0xa3,
};
// ASCII transliteration for any char with no CP852 mapping (last-resort, so an
// odd glyph degrades to a readable letter rather than garbage).
const ASCII_FALLBACK: Record<string, string> = {
ë: "e", Ë: "E", ç: "c", Ç: "C", ä: "a", ö: "o", ü: "u",
é: "e", á: "a", í: "i", ó: "o", ú: "u",
};
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
* through; mapped chars use their CP852 byte; unmapped non-ASCII falls back to an
* ASCII letter. Pair with SELECT_CP852 in the print preamble. */
function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
const out: number[] = [];
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
const mapped = CP852[ch];
const fallback = ASCII_FALLBACK[ch];
if (code < 0x80) {
out.push(code);
} else if (mapped !== undefined) {
out.push(mapped);
} else if (fallback !== undefined) {
out.push(...Buffer.from(fallback, "ascii"));
} else {
out.push(0x3f); // "?" — unknown char, never a wrong glyph
}
}
out.push(LF);
return Buffer.from(out);
}
// --- Scannable symbol (printer-generated, no image rendering) -----------------
// The ticket id is the session key (wiki/concepts/ticket-encoding.md). We print it
// as a 1D Code128 barcode so ANY legacy laser barcode scanner the booth might have
// can read it. The barcode is rendered by the Rongta board from these ESC/POS
// commands — we send the data, the firmware draws the bars (no bitmap, no
// dependency). The same code is printed as large human-readable digits below, so
// the operator can hand-key it if every reader fails. (A QR for phone scanning may
// be added later behind an admin toggle.)
/** GS k — Code128 1D barcode. Height/width set first, then HRI off, then data. */
function code128(data: string): Buffer {
// Code128 code set B (printable ASCII) — prefix the data with the {B selector.
const payload = Buffer.from(`{B${data}`, "ascii");
return Buffer.concat([
Buffer.from([GS, 0x68, 0x64]), // GS h 100 — barcode height = 100 dots (taller = tolerant of scan angle)
Buffer.from([GS, 0x77, 0x03]), // GS w 3 — module width = 3 (wider bars for the short-range "Simple" QR/barcode engine; 13-digit Code128 ≈ 495/576 dots, fits 80mm with quiet zones)
Buffer.from([GS, 0x48, 0x00]), // GS H 0 — HRI text off (we print the id ourselves)
// GS k 73 n <data> — function B form: 73 = Code128, n = data byte length.
Buffer.from([GS, 0x6b, 0x49, payload.length]),
payload,
]);
}
// Ticket/receipt strings — Albanian (the site prints in Albanian for now). Kept in
// one place so a real i18n layer (per-locale tables + a t() helper) can replace this
// later without touching the render functions. See wiki/concepts/site-metadata.md.
const STR = {
/** NIUS label prefix; printed only when the park has a NIUS. */
nius: (v: string) => `NIUS: ${v}`,
/** "Printed at:" — precedes the issue timestamp. */
issuedAt: (v: string) => `Printuar më: ${v}`,
/** "Lost your ticket? <phone>" footer; printed only when a phone is set. */
lostTicket: (phone: string) => `Keni humbur biletën? ${phone}`,
} as const;
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
function renderReport(report: PrintReport): Buffer {
return Buffer.concat([
INIT,
SELECT_CP852,
ALIGN_CENTER,
BOLD_ON,
line(report.title),
@@ -60,23 +136,44 @@ function renderReport(report: PrintReport): Buffer {
]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */
/** Render the park-identity header from site metadata. Prints the park name large
* (or "PARKING" if unset), then operator / NIUS / address lines that are present.
* NIUS and the rest only print when set. Non-ASCII renders via CP852 (see line()). */
function renderHeader(h: TicketData["header"]): Buffer {
const parts: Buffer[] = [ALIGN_CENTER, BOLD_ON, DOUBLE_ON, line(h?.parkName || "PARKING"), DOUBLE_OFF, BOLD_OFF];
if (h?.operatorName) parts.push(line(h.operatorName));
if (h?.nius) parts.push(line(STR.nius(h.nius)));
if (h?.address) {
// Address may be multi-line; print each line centered.
for (const ln of h.address.split(/\r?\n/)) if (ln.trim()) parts.push(line(ln.trim()));
}
return Buffer.concat(parts);
}
/** Build the full ESC/POS byte stream for an entry ticket.
* Header (park identity) → 1D Code128 barcode of the ticket id → the id in large
* digits → issue time → optional lost-ticket footer. Code128 is read by ANY legacy
* 1D barcode scanner the booth might have; the printed digits are the fallback if
* every reader fails (operator hand-keys the all-numeric code). Text is Albanian.
* See wiki/concepts/ticket-encoding.md and site-metadata.md. */
function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
SELECT_CP852,
renderHeader(data.header),
line(),
// The scannable barcode + the same code in large human-readable digits.
code128(data.ticketId),
line(),
BOLD_ON,
DOUBLE_ON,
line("PARKING"),
line(data.ticketId),
DOUBLE_OFF,
BOLD_OFF,
line(),
BOLD_ON,
line(data.ticketId),
BOLD_OFF,
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
line(STR.issuedAt(data.issuedAt)),
// Contact footer (lost-ticket help) if a phone is set.
...(data.header?.phone ? [line(STR.lostTicket(data.header.phone))] : []),
FEED_AND_CUT,
]);
}
+14
View File
@@ -190,9 +190,23 @@ export interface Snapshot {
}
// --- Printers (ticket dispenser / booth printer) -------------------------
/** Optional park identity printed at the top of a ticket/receipt. All fields
* optional — the driver prints only what's set. Sourced from site_config; an
* Albanian parking receipt commonly must show the park name + NIUS. */
export interface TicketHeader {
readonly parkName?: string | null;
readonly operatorName?: string | null;
/** NIUS — Albanian tax/identification number. */
readonly nius?: string | null;
readonly address?: string | null;
readonly phone?: string | null;
}
export interface TicketData {
readonly ticketId: string;
readonly issuedAt: string; // ISO-8601
/** Park identity for the header. Absent → driver prints the generic "PARKING". */
readonly header?: TicketHeader;
}
export interface PrinterDevice extends Device {