feat(subs): scannable out-of-window slip + two-step booth flow
The advisory out-of-window slip for a subscriber had two problems:
1. Faulty character codes. It rendered via the generic text printReport,
which has no CP852 mapping for the em dash, ellipsis, or warning sign in
the composed strings — so they printed as "?" ("PARKIM ? JASHTE ORARIT").
Added ASCII transliterations for that typographic punctuation in the
ESC/POS encoder (— → -, ⚠ → !, … → ..., curly quotes/bullet), so they
degrade to a readable glyph instead of "?".
2. Not scannable. The slip printed only "Nr: SUBSESS-…" as plain text, so
the operator had to hand-key it. Gave the notice its own render function
(renderWindowChargeNotice) + a printWindowChargeNotice device method that
prints the occurrence id as a Code128 AND a QR — the same scan path as a
transient ticket, so the operator scans it straight into the booth pay
modal, which then quotes the combined window charge. Implemented on both
the rongta and cashino drivers.
Also fixed the booth pay modal: "Open barrier" no longer shows by default
for a subscriber. A prepaid subscriber with nothing owed sees only a small
"assist open" reveal (the audited manual open for a faulty reader / lost
card stays available, just not the default). A subscriber owing an
out-of-window charge is now two steps — take payment first, then "Open
barrier" appears — instead of an always-on open button.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
||||
ReceiptData,
|
||||
SubscriptionCardData,
|
||||
TicketData,
|
||||
WindowChargeNoticeData,
|
||||
} from "../interfaces.js";
|
||||
|
||||
// Shared ESC/POS rendering + raw-TCP transport for 80mm thermal printers.
|
||||
@@ -68,6 +69,19 @@ const ASCII_FALLBACK: Record<string, string> = {
|
||||
í: "i",
|
||||
ó: "o",
|
||||
ú: "u",
|
||||
// Typographic punctuation that creeps in from composed strings — CP852 has no
|
||||
// em/en dash, ellipsis, curly quotes, or the warning sign, so without these they
|
||||
// print as "?" (the cause of the "PARKIM ? JASHTË ORARIT" misprint). Degrade to
|
||||
// the obvious ASCII equivalent rather than a literal "?".
|
||||
"—": "-", // em dash U+2014
|
||||
"–": "-", // en dash U+2013
|
||||
"…": "...", // ellipsis U+2026
|
||||
"‘": "'",
|
||||
"’": "'",
|
||||
"“": '"',
|
||||
"”": '"',
|
||||
"⚠": "!", // warning sign U+26A0 — no glyph on a thermal head; "!" reads as a flag
|
||||
"•": "*",
|
||||
};
|
||||
|
||||
/** Encode one line of text to CP852 bytes + a line feed. ASCII (<0x80) passes
|
||||
@@ -188,6 +202,21 @@ const STR = {
|
||||
],
|
||||
/** Thank-you footer. */
|
||||
thanks: "Faleminderit!",
|
||||
// --- out-of-window advisory slip (subscriber) ---
|
||||
/** Slip title. ASCII dash (not em dash) so no codepage surprise. */
|
||||
windowTitle: "PARKIM - JASHTË ORARIT",
|
||||
/** "Subscriber: <name>" line. */
|
||||
windowHolder: (name: string) => `Abonent: ${name}`,
|
||||
/** Entry-edge line; appends the window-open time when known. */
|
||||
windowEnteredEarly: (opensHHMM?: string) =>
|
||||
opensHHMM ? `Ka hyrë jashtë orarit (orari hap ${opensHHMM})` : "Ka hyrë jashtë orarit",
|
||||
/** Exit-edge line. */
|
||||
windowExitedLate: "Ka dalë jashtë orarit",
|
||||
/** "Entry:" / "Exit:" stamp label per edge. */
|
||||
windowStamp: (edge: "entry" | "exit", v: string) => (edge === "entry" ? `Hyrja: ${v}` : `Dalja: ${v}`),
|
||||
/** Two short lines (each fits 80mm) telling the customer a fee is pending and
|
||||
* is settled at the booth before exit. ASCII "!" flag (no glyph for ⚠). */
|
||||
windowPending: ["! Detyrim do të llogaritet në dalje", " (paguhet në kabinë para se të dilni)"] as const,
|
||||
} as const;
|
||||
|
||||
/** Format integer minor units + ISO-4217 currency as a major-unit string for the
|
||||
@@ -409,6 +438,48 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Build the ESC/POS byte stream for the ADVISORY out-of-window slip. Header →
|
||||
* title → a SCANNABLE Code128 + QR of the occurrence id (so the operator scans it
|
||||
* straight into the booth pay modal — same path as a transient ticket) → the id in
|
||||
* text (hand-key fallback) → holder + entry/exit stamp → the "pay at booth" notice.
|
||||
* Carries NO amount (the booth quotes the combined charge at settlement). Albanian. */
|
||||
export function renderWindowChargeNotice(data: WindowChargeNoticeData): Buffer {
|
||||
const hhmm = (m?: number | null) =>
|
||||
m == null ? undefined : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||
const parts: Buffer[] = [
|
||||
INIT,
|
||||
SELECT_CP852,
|
||||
renderHeader(data.header),
|
||||
line(),
|
||||
ALIGN_CENTER,
|
||||
BOLD_ON,
|
||||
line(STR.windowTitle),
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
// The occurrence id, scannable two ways: Code128 for a 1D laser scanner, QR for
|
||||
// the booth's combo reader. Either pulls the occurrence up in the pay modal.
|
||||
code128(data.occurrenceId),
|
||||
line(),
|
||||
qrCode(data.occurrenceId),
|
||||
line(),
|
||||
// The id in text, as the hand-key fallback if neither scans.
|
||||
line(data.occurrenceId),
|
||||
ALIGN_LEFT,
|
||||
line(),
|
||||
];
|
||||
if (data.holderName) parts.push(line(STR.windowHolder(data.holderName)));
|
||||
parts.push(line(STR.windowStamp(data.edge, stamp(data.at))));
|
||||
parts.push(
|
||||
line(data.edge === "entry" ? STR.windowEnteredEarly(hhmm(data.windowOpensMin)) : STR.windowExitedLate),
|
||||
line(),
|
||||
BOLD_ON,
|
||||
...STR.windowPending.map((l) => line(l)),
|
||||
BOLD_OFF,
|
||||
FEED_AND_CUT,
|
||||
);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the
|
||||
* whole stream before the connection tears down.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user