Files
parking_solution/apps/server/src/reports.ts
T
julian 8a437d0c4b
CI / check (push) Failing after 56s
feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00

289 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
and,
asc,
desc,
eq,
gte,
lte,
ledgerEvents,
sessions,
subscriptions,
tariffVersions,
tariffs,
type Db,
} from "@parking/db";
import { siteTz } from "./subscription-window.js";
// Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an
// admin sees on the Reports page are summed from the SIGNED, hash-chained
// ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report
// reconciles against — so a chart total always ties out to the drawer. Only the
// duration/occupancy view leans on the derived `sessions` cache, where the ledger is
// awkward (you'd have to pair every entry with its exit by hand); that's flagged as a
// cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md.
//
// All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local
// calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the
// peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no
// floats (money is integer minor units throughout).
export type Bucket = "hour" | "day" | "month";
export interface ReportQuery {
/** Inclusive lower bound (ISO instant). */
readonly from: string;
/** Exclusive upper bound (ISO instant). */
readonly to: string;
/** Time grain for the series. Default "day". */
readonly bucket: Bucket;
}
/** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22"
* for a day, "2026-06-22 14" for an hour). */
export interface SeriesPoint {
readonly bucket: string;
readonly entries: number;
readonly exits: number;
/** Net transient revenue collected in the bucket (minor units), all tenders. */
readonly revenueMinor: number;
/** Payment COUNT in the bucket (transactions, not amount). */
readonly payments: number;
}
export interface ReportTotals {
readonly entries: number;
readonly exits: number;
readonly payments: number;
readonly revenueMinor: number;
readonly cashMinor: number;
readonly cardMinor: number;
/** Revenue split by what was sold. ticket = transient parking; subscriptionSales =
* new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */
readonly ticketMinor: number;
readonly subscriptionSalesMinor: number;
readonly subscriptionWindowMinor: number;
/** Closed transient sessions in range + their parked-minutes stats (from the cache). */
readonly closedSessions: number;
readonly totalParkedMinutes: number;
readonly avgParkedMinutes: number;
readonly medianParkedMinutes: number;
}
export interface SubscriptionStats {
readonly active: number;
readonly suspended: number;
readonly revoked: number;
/** Active subscriptions whose window covers `to` (the report's "now"). */
readonly currentlyValid: number;
/** Cars covered by currently-valid subscriptions (Σ quantity). */
readonly coveredCars: number;
}
export interface ReportSummary {
readonly from: string;
readonly to: string;
readonly bucket: Bucket;
readonly tz: string;
readonly currency: string | null;
readonly totals: ReportTotals;
readonly series: SeriesPoint[];
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
readonly entriesByHour: number[];
readonly subscriptions: SubscriptionStats;
}
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
return {
y: Number(parts.year),
mo: Number(parts.month),
d: Number(parts.day),
h: Number(parts.hour),
};
}
/** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */
function bucketLabel(iso: string, tz: string, bucket: Bucket): string {
const p = localParts(iso, tz);
const mo = String(p.mo).padStart(2, "0");
const d = String(p.d).padStart(2, "0");
const h = String(p.h).padStart(2, "0");
if (bucket === "month") return `${p.y}-${mo}`;
if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`;
return `${p.y}-${mo}-${d}`;
}
interface PaymentPayload {
amountMinor?: number;
currency?: string;
tender?: "cash" | "card";
subscriptionSale?: boolean;
subscriptionWindowCharge?: boolean;
}
function median(sorted: number[]): number {
if (sorted.length === 0) return 0;
const mid = Math.floor(sorted.length / 2);
const hi = sorted[mid] ?? 0;
if (sorted.length % 2) return hi;
const lo = sorted[mid - 1] ?? 0;
return Math.round((lo + hi) / 2);
}
/**
* Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts
* and money are summed from the signed ledger; duration stats from the closed sessions
* in range; subscription counts from the subscriptions table as of `to`.
*/
export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
const tz = siteTz(db);
// --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order.
const rows = db
.select()
.from(ledgerEvents)
.where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to)))
.orderBy(asc(ledgerEvents.index))
.all();
// Currency for display: money everywhere is { minorUnits, currency }; payments carry
// the currency they were taken in, so take it from a payment in range (then fall back
// to the active tariff version). Reports never mix currencies (single-currency site).
let currency: string | null = null;
const seriesMap = new Map<string, SeriesPoint>();
const entriesByHour = new Array<number>(24).fill(0);
const totals = {
entries: 0,
exits: 0,
payments: 0,
revenueMinor: 0,
cashMinor: 0,
cardMinor: 0,
ticketMinor: 0,
subscriptionSalesMinor: 0,
subscriptionWindowMinor: 0,
};
function point(label: string): SeriesPoint {
let p = seriesMap.get(label);
if (!p) {
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
seriesMap.set(label, p);
}
return p;
}
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
// entry is normally in the same window; this skips it when both are in range.)
const voided = new Set<string>();
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
for (const row of rows) {
const label = bucketLabel(row.occurredAt, tz, q.bucket);
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
if (row.type === "vehicle_entry") {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
} else if (row.type === "vehicle_exit") {
totals.exits++;
p.exits++;
} else if (row.type === "payment") {
const pl = (row.payload ?? {}) as PaymentPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (!currency && typeof pl.currency === "string") currency = pl.currency;
totals.payments++;
totals.revenueMinor += amt;
p.payments++;
p.revenueMinor += amt;
if (pl.tender === "card") totals.cardMinor += amt;
else totals.cashMinor += amt;
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
// (the rest is) transient ticket revenue.
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt;
else totals.ticketMinor += amt;
}
}
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
// No payment in range? Fall back to the site tariff's latest version currency, so a
// zero-revenue range still labels its money column.
if (!currency) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (tariff) {
const tv = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.get();
currency = tv?.currency ?? null;
}
}
// --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged).
const closed = db
.select()
.from(sessions)
.where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to)))
.all();
const durations: number[] = [];
for (const s of closed) {
if (!s.enteredAt || !s.exitedAt) continue;
const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000));
durations.push(mins);
}
durations.sort((a, b) => a - b);
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
// --- Subscriptions: status counts + currently-valid (window covers `to`).
const subs = db.select().from(subscriptions).all();
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
for (const s of subs) {
if (s.status === "active") subStats.active++;
else if (s.status === "suspended") subStats.suspended++;
else if (s.status === "revoked") subStats.revoked++;
const validNow =
s.status === "active" &&
(!s.validFrom || s.validFrom <= q.to) &&
(!s.validTo || s.validTo >= q.to);
if (validNow) {
subStats.currentlyValid++;
subStats.coveredCars += s.quantity ?? 1;
}
}
return {
from: q.from,
to: q.to,
bucket: q.bucket,
tz,
currency,
totals: {
...totals,
closedSessions: durations.length,
totalParkedMinutes,
avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0,
medianParkedMinutes: median(durations),
},
series,
entriesByHour,
subscriptions: subStats,
};
}