Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbf61c48df | |||
| 00f3d141b6 |
@@ -219,18 +219,24 @@ export class EntryFlow {
|
||||
/**
|
||||
* Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md).
|
||||
*
|
||||
* Format: 13 digits = 12 cryptographically-random digits + 1 trailing Luhn check
|
||||
* Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check
|
||||
* digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and
|
||||
* an operator can hand-key it if every reader is down. RANDOM (not sequential): the
|
||||
* id must stay unguessable so an attacker can't iterate to claim a cheaper session
|
||||
* — the anti-fraud property the wiki settles. 12 random digits = 10^12 space, so
|
||||
* collisions are negligible at lot scale; the unique constraints on
|
||||
* ledger_events.index / sessions.id are the backstop. The Luhn digit lets a manual
|
||||
* entry reject a typo (validateTicketCode) instead of failing as "session not found".
|
||||
* — the anti-fraud property the wiki settles.
|
||||
*
|
||||
* Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the
|
||||
* Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN
|
||||
* ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while
|
||||
* being two digits (≈2 barcode modules) narrower than the old 13. Collisions are
|
||||
* negligible at lot scale; the unique constraints on ledger_events.index / sessions.id
|
||||
* are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.)
|
||||
* The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of
|
||||
* failing as "session not found".
|
||||
*/
|
||||
function newTicketId(): string {
|
||||
let body = "";
|
||||
for (let i = 0; i < 12; i += 1) body += String(randomInt(10));
|
||||
for (let i = 0; i < 10; i += 1) body += String(randomInt(10));
|
||||
return body + luhnCheckDigit(body);
|
||||
}
|
||||
|
||||
@@ -260,7 +266,11 @@ function luhnCheckDigit(digits: string): string {
|
||||
* never reject an id that already exists in the ledger. See ticket-encoding.md.
|
||||
*/
|
||||
export function validateTicketCode(code: string): boolean {
|
||||
if (!/^\d{13}$/.test(code)) return false;
|
||||
const body = code.slice(0, 12);
|
||||
return luhnCheckDigit(body) === code[12];
|
||||
// Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest.
|
||||
// Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation
|
||||
// (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps
|
||||
// a stray short/long string from being mistaken for a ticket. See ticket-encoding.md.
|
||||
if (!/^\d{10,14}$/.test(code)) return false;
|
||||
const body = code.slice(0, -1);
|
||||
return luhnCheckDigit(body) === code[code.length - 1];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq, devices, ledgerEvents, type Db } from "@parking/db";
|
||||
import { registry, type PrinterDevice } from "@parking/devices";
|
||||
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
|
||||
import type { LedgerPayload } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
@@ -393,21 +393,23 @@ export class ShiftService {
|
||||
}
|
||||
const cur = r.currency ?? "";
|
||||
const money = (m: number) => (m / 100).toFixed(2);
|
||||
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
|
||||
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
|
||||
const lines = [
|
||||
`Operator: ${r.operator}`,
|
||||
`From: ${r.startedAt}`,
|
||||
`To: ${r.endedAt}`,
|
||||
`Operatori: ${r.operator}`,
|
||||
`Nga: ${zStamp(r.startedAt)}`,
|
||||
`Deri: ${zStamp(r.endedAt)}`,
|
||||
"",
|
||||
`Payments: ${r.paymentCount}`,
|
||||
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Card: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
`Pagesa: ${r.paymentCount}`,
|
||||
`Para në dorë: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Kartë: ${money(r.cardTotalMinor)} ${cur}`,
|
||||
"",
|
||||
"-- Drawer --",
|
||||
`Opening float: ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Cash taken: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Cash added: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Cash removed: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
"-- Arka --",
|
||||
`Fillimi (kusur): ${money(r.openingFloatMinor)} ${cur}`,
|
||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||
];
|
||||
try {
|
||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatTime } from "./lib/format.js";
|
||||
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Panel } from "./ui/Panel.js";
|
||||
|
||||
// Active Sessions panel. A session is "active" while still inside OR exited-but-
|
||||
@@ -94,9 +94,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
|
||||
<span className="text-term-text">
|
||||
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
|
||||
</span>
|
||||
<span className="text-term-muted">
|
||||
{t("booth.inAt")} {formatTime(s.enteredAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
|
||||
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
|
||||
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { formatDuration, formatMoney, formatTime } from "./lib/format.js";
|
||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
|
||||
|
||||
// The booth pay/exit modal. Opened when the operator submits a ticket id. Shows the
|
||||
@@ -213,7 +213,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
<>
|
||||
{/* Session figures */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 tabular-nums">
|
||||
<Row label={t("pay.entry")} value={formatTime(s.enteredAt)} />
|
||||
<Row label={t("pay.entry")} value={formatRelativeDateTime(s.enteredAt, t)} />
|
||||
<Row label={t("pay.now")} value={formatTime(new Date().toISOString())} />
|
||||
<Row
|
||||
label={t("pay.duration")}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchShifts, type ShiftSummary, type SessionUser } from "./api.js";
|
||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Completed shift history. Scope is enforced SERVER-SIDE by permission: an operator
|
||||
// gets only their own shifts; an admin (shift:cash) gets all + a date/operator
|
||||
@@ -10,19 +10,6 @@ import { formatMoney, formatDuration } from "./lib/format.js";
|
||||
// reports scope:"all". Each row is one signed shift_z_report; expanding it shows the
|
||||
// drawer reconciliation. See wiki/concepts/shift.md.
|
||||
|
||||
/** Local date + time (history spans days, so not just time-of-day). */
|
||||
function fmtDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function money(minor: number, currency: string | null): string {
|
||||
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
|
||||
}
|
||||
@@ -138,6 +125,7 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const cur = s.currency;
|
||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,9 +134,9 @@ function ShiftRow({ s, showOperator, colSpan }: { s: ShiftSummary; showOperator:
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{showOperator && <td className="px-3 py-1.5 text-term-text">{s.operator}</td>}
|
||||
<td className="px-3 py-1.5">{fmtDateTime(s.startedAt)}</td>
|
||||
<td className="px-3 py-1.5">{when(s.startedAt)}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
{fmtDateTime(s.endedAt)}
|
||||
{when(s.endedAt)}
|
||||
<span className="ml-2 text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right">{s.paymentCount}</td>
|
||||
|
||||
@@ -28,3 +28,58 @@ export function formatTime(iso: string | null): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toTimeString().slice(0, 8);
|
||||
}
|
||||
|
||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||
* before ref, etc. Compares date parts only (ignores time-of-day). */
|
||||
function dayDiff(d: Date, ref: Date): number {
|
||||
const a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const b = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate());
|
||||
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** HH:MM (local, 24h) for the relative-day labels. */
|
||||
function hhmm(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Minimal shape of i18next's `t` that we rely on: a string lookup, plus the
|
||||
* `returnObjects` overload used to fetch the month-name array. */
|
||||
export interface TFn {
|
||||
(key: string): string;
|
||||
(key: string, opts: { returnObjects: true }): unknown;
|
||||
}
|
||||
|
||||
/** Localized month name (index 0 = January) from the i18n catalog. Browser ICU on
|
||||
* the appliance may lack Albanian data, so we DON'T use Intl — the catalog is the
|
||||
* source of truth. Falls back to a numeric month if the array is missing. */
|
||||
function monthName(d: Date, t: TFn): string {
|
||||
const months = t("common.months", { returnObjects: true });
|
||||
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||
return months[d.getMonth()] as string;
|
||||
}
|
||||
return String(d.getMonth() + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||
* "17 Qershor 10:48" (month name from the active catalog). Keeps time-of-day on
|
||||
* every variant — operators care about it within a shift.
|
||||
*
|
||||
* `t` supplies the today/yesterday words AND the month names (the appliance browser
|
||||
* may lack Albanian Intl data, so month names come from the catalog, not Intl).
|
||||
*/
|
||||
export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const diff = dayDiff(d, new Date());
|
||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
const month = monthName(d, t);
|
||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
||||
return `${date} ${hhmm(d)}`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,22 @@ export const en: Catalog = {
|
||||
themeDark: "dark",
|
||||
themeLight: "light",
|
||||
theme: "Theme",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
months: [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
title: "Parking System",
|
||||
@@ -83,7 +99,6 @@ export const en: Catalog = {
|
||||
activeSessions: "Active sessions",
|
||||
insideCount: "inside",
|
||||
noActiveSessions: "No active sessions.",
|
||||
inAt: "in",
|
||||
openPayExit: "Open pay / exit",
|
||||
openBarrier: "Open barrier",
|
||||
openBarrierTitle: "Human-intervention barrier open (audited)",
|
||||
@@ -152,7 +167,7 @@ export const en: Catalog = {
|
||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||
"exit.refused.closed": "Exit refused — session already closed",
|
||||
"exit.refused.noSession": "Exit refused — no open session for ticket",
|
||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||
"exit.refused.graceExpired": "Exit refused — walk-back grace expired (top-up required)",
|
||||
"exit.open.noBarrier": "Exit recorded, but no exit barrier is configured — open manually",
|
||||
|
||||
@@ -14,6 +14,24 @@ export const sq = {
|
||||
themeDark: "errët",
|
||||
themeLight: "çelët",
|
||||
theme: "Tema",
|
||||
today: "Sot",
|
||||
yesterday: "Dje",
|
||||
// Month names (index 0 = January) — kept in the catalog because the appliance's
|
||||
// browser ICU may lack Albanian locale data (Intl falls back to English).
|
||||
months: [
|
||||
"Janar",
|
||||
"Shkurt",
|
||||
"Mars",
|
||||
"Prill",
|
||||
"Maj",
|
||||
"Qershor",
|
||||
"Korrik",
|
||||
"Gusht",
|
||||
"Shtator",
|
||||
"Tetor",
|
||||
"Nëntor",
|
||||
"Dhjetor",
|
||||
],
|
||||
},
|
||||
auth: {
|
||||
title: "Sistemi i Parkimit",
|
||||
@@ -83,7 +101,6 @@ export const sq = {
|
||||
activeSessions: "Sesionet aktive",
|
||||
insideCount: "brenda",
|
||||
noActiveSessions: "Asnjë sesion aktiv.",
|
||||
inAt: "në",
|
||||
openPayExit: "Hap pagesën / daljen",
|
||||
openBarrier: "Hap barrierën",
|
||||
openBarrierTitle: "Hap barrierën manualisht",
|
||||
|
||||
+21
-7
@@ -99,10 +99,17 @@ function LanguageToggle({
|
||||
user: SessionUser;
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
// The ACTIVE language is i18n's own state, not the router-context `user` — the
|
||||
// latter is captured at route-resolution time and does NOT re-render when we call
|
||||
// setUser, so reading `user.language` here goes stale after the first switch (the
|
||||
// highlight froze and the equality guard blocked switching back until a refresh).
|
||||
// useTranslation() subscribes to i18n's languageChanged, so this stays live.
|
||||
const { i18n } = useTranslation();
|
||||
const active = i18n.language as Lang;
|
||||
async function pick(lang: Lang) {
|
||||
if (lang === user.language) return;
|
||||
setLanguage(lang); // instant UI
|
||||
setUser({ ...user, language: lang });
|
||||
if (lang === active) return;
|
||||
setLanguage(lang); // instant UI (fires i18n languageChanged → re-render)
|
||||
setUser({ ...user, language: lang }); // keep context eventually-consistent + persisted state
|
||||
try {
|
||||
await setLanguagePref(lang); // persist
|
||||
} catch {
|
||||
@@ -117,7 +124,7 @@ function LanguageToggle({
|
||||
type="button"
|
||||
onClick={() => pick(l)}
|
||||
className={`rounded-term px-1.5 py-0.5 ${
|
||||
user.language === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
active === l ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
@@ -138,10 +145,17 @@ function ThemeToggle({
|
||||
setUser: (u: SessionUser | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Local state for the ACTIVE theme — same reason as LanguageToggle: the router
|
||||
// context `user` doesn't re-render on setUser, so reading `user.theme` here froze
|
||||
// the highlight after one switch and blocked toggling back until a refresh. Seed
|
||||
// from the prop; update optimistically on pick. App's effect keeps the DOM in sync
|
||||
// with the persisted user on (re)login.
|
||||
const [active, setActive] = useState<Theme>(user.theme);
|
||||
async function pick(theme: Theme) {
|
||||
if (theme === user.theme) return;
|
||||
if (theme === active) return;
|
||||
setActive(theme);
|
||||
applyTheme(theme); // instant UI
|
||||
setUser({ ...user, theme });
|
||||
setUser({ ...user, theme }); // keep context eventually-consistent + persisted state
|
||||
try {
|
||||
await setThemePref(theme); // persist
|
||||
} catch {
|
||||
@@ -156,7 +170,7 @@ function ThemeToggle({
|
||||
type="button"
|
||||
onClick={() => pick(th)}
|
||||
className={`rounded-term px-1.5 py-0.5 ${
|
||||
user.theme === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
active === th ? "bg-term-panel-2 text-term-amber" : "text-term-muted hover:text-term-text"
|
||||
}`}
|
||||
>
|
||||
{t(th === "dark" ? "common.themeDark" : "common.themeLight")}
|
||||
|
||||
@@ -215,13 +215,43 @@ function duration(fromIso: string, toIso: string): string {
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** Local date+time "YYYY-MM-DD HH:MM" for a receipt row. The host clock is the
|
||||
* site's local time (the appliance runs in the site's zone). */
|
||||
function stamp(iso: string): string {
|
||||
/** Albanian month names (customer-facing receipts are always Albanian — see
|
||||
* i18n.md). Indexed by Date.getMonth() (0 = Janar). */
|
||||
const SQ_MONTHS = [
|
||||
"Janar",
|
||||
"Shkurt",
|
||||
"Mars",
|
||||
"Prill",
|
||||
"Maj",
|
||||
"Qershor",
|
||||
"Korrik",
|
||||
"Gusht",
|
||||
"Shtator",
|
||||
"Tetor",
|
||||
"Nëntor",
|
||||
"Dhjetor",
|
||||
] as const;
|
||||
|
||||
/** Human local date+time for a receipt row, e.g. "19 Qershor 2026 10:48:25". The
|
||||
* host clock is the site's local time (the appliance runs in the site's zone);
|
||||
* 24-hour with seconds (Albania uses 24h). Falls back to the raw ISO on a bad date.
|
||||
* Exported (as formatStampSq) so other server-side printed output — e.g. the shift
|
||||
* Z-report — shares one Albanian date format. */
|
||||
export function stamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
const date = `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||||
const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
/** Date-only Albanian format "19 Qershor 2026" (for subscription validity dates,
|
||||
* which are date strings with no time). Passes through a non-date value unchanged. */
|
||||
function dateOnly(value: string): string {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return `${d.getDate()} ${SQ_MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** Build the ESC/POS byte stream for a free-form text report (e.g. shift Z-report). */
|
||||
@@ -284,7 +314,7 @@ export function renderTicket(data: TicketData): Buffer {
|
||||
DOUBLE_OFF,
|
||||
BOLD_OFF,
|
||||
line(),
|
||||
line(STR.issuedAt(data.issuedAt)),
|
||||
line(STR.issuedAt(stamp(data.issuedAt))),
|
||||
FEED_AND_CUT,
|
||||
]);
|
||||
}
|
||||
@@ -312,7 +342,9 @@ export function renderSubscriptionCard(data: SubscriptionCardData): Buffer {
|
||||
];
|
||||
if (data.holderName) parts.push(line(STR.holder(data.holderName)));
|
||||
if (data.validFrom || data.validTo) {
|
||||
parts.push(line(STR.validity(data.validFrom ?? "—", data.validTo ?? "—")));
|
||||
parts.push(
|
||||
line(STR.validity(data.validFrom ? dateOnly(data.validFrom) : "—", data.validTo ? dateOnly(data.validTo) : "—")),
|
||||
);
|
||||
}
|
||||
parts.push(FEED_AND_CUT);
|
||||
return Buffer.concat(parts);
|
||||
@@ -377,7 +409,15 @@ export function renderReceipt(data: ReceiptData): Buffer {
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/** Open a TCP socket, write the bytes, wait for flush, then close. */
|
||||
/** Open a TCP socket, write the bytes, and close GRACEFULLY so the printer reads the
|
||||
* whole stream before the connection tears down.
|
||||
*
|
||||
* Why not write-then-destroy: a Socket.write() callback fires when the data reaches
|
||||
* the local kernel buffer, NOT when the peer has read it. Calling destroy() at that
|
||||
* point sends a TCP RST that can truncate the job in flight — the printer then has a
|
||||
* desynced ESC/POS stream and prints raster garbage (solid black bars / banding).
|
||||
* Instead we `end(payload)` (write + FIN) and wait for the socket to fully close,
|
||||
* which only happens after the peer has drained our bytes and the FIN is acked. */
|
||||
export function sendRaw(
|
||||
host: string,
|
||||
port: number,
|
||||
@@ -387,17 +427,37 @@ export function sendRaw(
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = new Socket();
|
||||
let settled = false;
|
||||
const done = (err?: Error) => {
|
||||
// True once the payload + FIN have been handed off (flushed locally). After this,
|
||||
// we've done our part; a slow/absent peer-FIN should NOT fail an already-sent job.
|
||||
let written = false;
|
||||
const fail = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
err ? reject(err) : resolve();
|
||||
reject(err);
|
||||
};
|
||||
const succeed = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
sock.destroy();
|
||||
resolve();
|
||||
};
|
||||
sock.setTimeout(timeoutMs);
|
||||
sock.on("timeout", () => done(new Error("timeout")));
|
||||
sock.on("error", done);
|
||||
// A timeout BEFORE the bytes are out is a real failure; one AFTER (some printers
|
||||
// never send their FIN, holding the socket open) means the job was delivered —
|
||||
// succeed rather than reject a ticket that already printed.
|
||||
sock.on("timeout", () => (written ? succeed() : fail(new Error("timeout"))));
|
||||
sock.on("error", fail);
|
||||
// `close` fires after the bytes are flushed AND the connection is fully torn down
|
||||
// (our FIN sent, peer's FIN received) — the job has been delivered by then.
|
||||
sock.on("close", (hadError) => (hadError ? undefined : succeed()));
|
||||
sock.connect(port, host, () => {
|
||||
sock.write(payload, (err) => (err ? done(err) : done()));
|
||||
// end() writes the payload then sends FIN — a graceful half-close that lets the
|
||||
// printer finish reading before the socket closes. No abrupt destroy(). The
|
||||
// write callback confirms the bytes left our buffer.
|
||||
sock.end(payload, () => {
|
||||
written = true;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export {
|
||||
cashinoDriver,
|
||||
} from "./drivers/index.js";
|
||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
||||
// kept in one place so all printed output formats dates identically.
|
||||
export { stamp as formatStampSq } from "./drivers/printer-escpos.js";
|
||||
export {
|
||||
orderForRole,
|
||||
printWithFailover,
|
||||
|
||||
Reference in New Issue
Block a user