f2734641b2
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).
Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.
(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
193 lines
7.5 KiB
TypeScript
193 lines
7.5 KiB
TypeScript
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
|
|
import {
|
|
formatStampSq,
|
|
printWithFailover,
|
|
registry,
|
|
type PrinterDevice,
|
|
type PrinterInstance,
|
|
type ReceiptData,
|
|
type TicketHeader,
|
|
} from "@parking/devices";
|
|
import type { FastifyBaseLogger } from "fastify";
|
|
import { devicesByDirection } from "./device-resolve.js";
|
|
|
|
// Booth-side printing for the EXIT VOUCHER ("biletë dalje"). When the booth is far
|
|
// from the exit, the customer pays at the booth and walks a printed voucher to the
|
|
// exit, where they self-scan it. The voucher reprints the SAME ticket id as a
|
|
// Code128 barcode (now a paid session) — so the exit reader runs the normal exit
|
|
// validation and opens. See wiki/concepts/booth-exit-flow.md, ticket-encoding.md.
|
|
//
|
|
// This mirrors the entry flow's printer selection + header build, but prints on the
|
|
// BOOTH printer (role "booth-receipt") since that's where the operator stands.
|
|
|
|
/** Park identity for the voucher header, from site_config (all fields optional). */
|
|
function ticketHeader(db: Db): TicketHeader | undefined {
|
|
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
if (!row) return undefined;
|
|
return {
|
|
parkName: row.parkName,
|
|
operatorName: row.operatorName,
|
|
nius: row.nius,
|
|
address: row.address,
|
|
phone: row.phone,
|
|
};
|
|
}
|
|
|
|
/** Build live printer instances for failover selection (entry direction covers the
|
|
* booth-receipt role too — the booth printer is configured on the entry side). */
|
|
function loadPrinters(db: Db): PrinterInstance[] {
|
|
const rows = devicesByDirection(db, "printer", "entry");
|
|
const out: PrinterInstance[] = [];
|
|
for (const row of rows) {
|
|
const driver = registry.get(row.driverId);
|
|
if (!driver) continue;
|
|
const cfg = row.config as Record<string, unknown>;
|
|
const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser";
|
|
try {
|
|
out.push({
|
|
id: row.id,
|
|
role,
|
|
failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0,
|
|
device: driver.create(cfg as never) as PrinterDevice,
|
|
});
|
|
} catch {
|
|
// skip a printer whose config won't build
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The receipt figures for a paid session, folded from the SIGNED ledger
|
|
* (authoritative). Null if there's no entry or no payment for this id — the
|
|
* caller should have validated paid + open before printing. */
|
|
function receiptFigures(
|
|
db: Db,
|
|
ticketId: string,
|
|
): Omit<ReceiptData, "voucher" | "header"> | null {
|
|
const rows = db
|
|
.select()
|
|
.from(ledgerEvents)
|
|
.where(eq(ledgerEvents.identity, ticketId))
|
|
.orderBy(ledgerEvents.index)
|
|
.all();
|
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
|
if (!entry) return null;
|
|
// The LATEST payment is the one we receipt (an overstay top-up re-pays).
|
|
let payment: (typeof rows)[number] | undefined;
|
|
for (const r of rows) if (r.type === "payment") payment = r;
|
|
if (!payment) return null;
|
|
const p = (payment.payload ?? {}) as {
|
|
amountMinor?: number;
|
|
currency?: string;
|
|
tender?: "cash" | "card";
|
|
graceExitMin?: number;
|
|
};
|
|
return {
|
|
ticketId,
|
|
enteredAt: entry.occurredAt,
|
|
paidAt: payment.occurredAt,
|
|
amountMinor: typeof p.amountMinor === "number" ? p.amountMinor : 0,
|
|
currency: p.currency ?? "ALL",
|
|
tender: p.tender === "card" ? "card" : "cash",
|
|
graceExitMin: typeof p.graceExitMin === "number" ? p.graceExitMin : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Print a PAYMENT RECEIPT for a paid session on the booth printer (failing over
|
|
* to the entry dispenser). The receipt is the customer's transparency record:
|
|
* entry time, payment time, duration, amount + tender — folded from the signed
|
|
* ledger. In VOUCHER mode it also carries the scannable ticket-id barcode + the
|
|
* walk-back grace, so the one slip both proves payment AND self-exits at a
|
|
* distant exit reader (this replaces the old barcode-only voucher). In standalone
|
|
* mode (`voucher:false`) it is detail-only, printed at payment when the booth is
|
|
* at the exit. Returns the id of the printer that printed it.
|
|
* Throws NoPrinterAvailableError if none can; throws if the session isn't payable.
|
|
*/
|
|
export async function printPaymentReceipt(
|
|
db: Db,
|
|
ticketId: string,
|
|
opts: { voucher: boolean },
|
|
logger: FastifyBaseLogger,
|
|
): Promise<string> {
|
|
const figures = receiptFigures(db, ticketId);
|
|
if (!figures) {
|
|
throw new Error(`no paid session to receipt for ${ticketId}`);
|
|
}
|
|
const printers = loadPrinters(db);
|
|
const data: ReceiptData = {
|
|
...figures,
|
|
voucher: opts.voucher,
|
|
header: ticketHeader(db),
|
|
};
|
|
// Prefer the booth printer (operator is at the booth); fall back to the dispenser.
|
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
|
d.printReceipt(data),
|
|
);
|
|
logger.info(
|
|
`${opts.voucher ? "exit voucher" : "payment receipt"} for ${ticketId} printed on ${printedBy}`,
|
|
);
|
|
return printedBy;
|
|
}
|
|
|
|
/**
|
|
* Print a SUBSCRIPTION CARD on the booth printer (failing over to the dispenser):
|
|
* a scannable QR of the credential code + holder/validity, so the operator can hand
|
|
* it to the customer. Used on subscription creation and on a "reprint" action.
|
|
* Returns the printer that printed it; throws NoPrinterAvailableError if none can.
|
|
*/
|
|
export async function printSubscriptionCard(
|
|
db: Db,
|
|
card: { code: string; holderName?: string | null; validFrom?: string | null; validTo?: string | null },
|
|
logger: FastifyBaseLogger,
|
|
): Promise<string> {
|
|
const printers = loadPrinters(db);
|
|
const data = {
|
|
code: card.code,
|
|
holderName: card.holderName ?? null,
|
|
validFrom: card.validFrom ?? null,
|
|
validTo: card.validTo ?? null,
|
|
header: ticketHeader(db),
|
|
};
|
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
|
d.printSubscriptionCard(data),
|
|
);
|
|
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
|
|
return printedBy;
|
|
}
|
|
|
|
/**
|
|
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
|
|
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
|
|
* the total is computed at the booth on settlement (early-entry AND any late-exit time
|
|
* combined). It just gives the subscriber paper proof that a fee is pending against this
|
|
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
|
|
* the caller swallows failures so a missing printer never blocks the barrier.
|
|
*/
|
|
export async function printWindowChargeNotice(
|
|
db: Db,
|
|
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
|
|
logger: FastifyBaseLogger,
|
|
): Promise<string> {
|
|
const printers = loadPrinters(db);
|
|
const hhmm = (m?: number) =>
|
|
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
const lines = [
|
|
`Abonent: ${notice.holderName || "-"}`,
|
|
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
|
|
notice.edge === "entry"
|
|
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
|
|
: "Ka dalë jashtë orarit",
|
|
"",
|
|
"⚠ Detyrim do të llogaritet në dalje",
|
|
" (paguhet në kabinë para se të dilni)",
|
|
"",
|
|
`Nr: ${notice.occurrenceId}`,
|
|
];
|
|
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
|
|
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
|
|
);
|
|
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
|
|
return printedBy;
|
|
}
|