06dab1e790
Backend: PayStation.lookup (session view + quote in one read); ExitFlow.exitForBooth reuses the reader path's paid+grace validation (no booth-only unpaid bypass) and signs vehicle_exit + pulses an exit relay; printExitVoucher reprints the paid ticket id barcode; site_config.exit_voucher_default (migration 0002) drives the default. Routes: GET /api/session/:id, POST /api/exit, POST /api/voucher. Web: BoothPayModal (entry/now/duration/total, tender, 'Printo biletë dalje'), SnapshotStrip (entry/exit evidence), api.ts client fns, SiteSettings toggle.
82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
import { eq, siteConfig, type Db } from "@parking/db";
|
|
import {
|
|
printWithFailover,
|
|
registry,
|
|
type PrinterDevice,
|
|
type PrinterInstance,
|
|
type TicketData,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Print an exit voucher for a paid session: the same ticket id reprinted as a
|
|
* barcode, on the booth printer (failing over to the entry dispenser). Returns the
|
|
* id of the printer that printed it. Throws NoPrinterAvailableError if none can.
|
|
*/
|
|
export async function printExitVoucher(
|
|
db: Db,
|
|
ticketId: string,
|
|
logger: FastifyBaseLogger,
|
|
): Promise<string> {
|
|
const printers = loadPrinters(db);
|
|
const ticket: TicketData = {
|
|
ticketId,
|
|
issuedAt: new Date().toISOString(),
|
|
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.printTicket(ticket),
|
|
);
|
|
logger.info(`exit voucher for ${ticketId} printed on ${printedBy}`);
|
|
return printedBy;
|
|
}
|