feat(reports): admin Reports dashboard — ledger-first charts
Adds an admin Reports screen (/setup/reports, gated report:read) — an on-demand dashboard over the signed event log. Server (ledger-first): GET /api/reports/summary?from&to&bucket aggregates in one call — entry/exit counts + all money summed straight from ledger_events (same source the shift Z-report reconciles, so totals tie out to the drawer); revenue split into ticket / subscription-sale / out-of-window mirrors the Z-report. Duration stats come from the sessions cache (flagged). All bucketing is in the SITE timezone (siteTz). A .csv export of the per-bucket series. reports.ts + routes/reports.ts. Web: Reports.tsx — date-range presets (today/7d/30d/90d), hour/day/month grain, KPI cards, entry/exit line, revenue bar + cash/card split, revenue-mix pie, peak-hours histogram, numeric breakdown, subscription stats. Charts via Recharts (MIT), lazy-loaded into its own chunk (~111KB gz) so the booth bundle is untouched. New Setup tab + nav + i18n (sq + en parity). asc() exported from @parking/db; formatMinutes helper. Tests: reports.test.ts (10) pin the sums, tz bucketing, money split, duration stats, subscription counts. server 90/90; build+lint 14/14. Wiki: reporting-analytics.md "Built v1" section + log entry. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { sessions, siteConfig, subscriptions, type Db } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { makeLog } from "./test-helpers.js";
|
||||
import { reportSummary } from "./reports.js";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
// Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are
|
||||
// summed straight from the signed ledger (entry/exit counts + payment money, split the
|
||||
// same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration
|
||||
// stats from the closed-sessions cache and subscription counts as of the range end.
|
||||
|
||||
let db: Db;
|
||||
let log: EventLog;
|
||||
|
||||
beforeEach(() => {
|
||||
({ db } = createTestDb());
|
||||
log = makeLog(db);
|
||||
// Fix the site timezone so bucket labels are deterministic regardless of the test host.
|
||||
db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run();
|
||||
});
|
||||
|
||||
/** ISO at a UTC instant, for deterministic bucket assertions. */
|
||||
function at(iso: string): string {
|
||||
return new Date(iso).toISOString();
|
||||
}
|
||||
|
||||
async function entry(occurredAt: string): Promise<void> {
|
||||
await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt });
|
||||
}
|
||||
async function exit(occurredAt: string): Promise<void> {
|
||||
await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt });
|
||||
}
|
||||
async function payment(
|
||||
occurredAt: string,
|
||||
amountMinor: number,
|
||||
opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await log.append({
|
||||
type: "payment",
|
||||
occurredAt,
|
||||
payload: {
|
||||
amountMinor,
|
||||
currency: "ALL",
|
||||
tender: opts.tender ?? "cash",
|
||||
...(opts.subscriptionSale ? { subscriptionSale: true } : {}),
|
||||
...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") };
|
||||
|
||||
describe("reportSummary — ledger-first totals", () => {
|
||||
it("counts entries and exits from the signed ledger", async () => {
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await entry(at("2026-06-10T09:00:00Z"));
|
||||
await exit(at("2026-06-10T18:00:00Z"));
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.entries).toBe(2);
|
||||
expect(r.totals.exits).toBe(1);
|
||||
});
|
||||
|
||||
it("excludes events outside [from, to)", async () => {
|
||||
await entry(at("2026-05-31T23:00:00Z")); // before
|
||||
await entry(at("2026-06-15T10:00:00Z")); // inside
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.entries).toBe(1);
|
||||
});
|
||||
|
||||
it("sums payment money and splits cash vs card", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" });
|
||||
await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.payments).toBe(2);
|
||||
expect(r.totals.revenueMinor).toBe(25000);
|
||||
expect(r.totals.cashMinor).toBe(20000);
|
||||
expect(r.totals.cardMinor).toBe(5000);
|
||||
});
|
||||
|
||||
it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket
|
||||
await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true });
|
||||
await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true });
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.ticketMinor).toBe(10000);
|
||||
expect(r.totals.subscriptionSalesMinor).toBe(30000);
|
||||
expect(r.totals.subscriptionWindowMinor).toBe(1500);
|
||||
// The three add up to the gross revenue.
|
||||
expect(r.totals.revenueMinor).toBe(41500);
|
||||
});
|
||||
|
||||
it("picks up the currency from a payment in range", async () => {
|
||||
await payment(at("2026-06-12T10:00:00Z"), 10000);
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.currency).toBe("ALL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — time bucketing (site timezone)", () => {
|
||||
it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => {
|
||||
// 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket.
|
||||
await entry(at("2026-06-15T23:30:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const point = r.series.find((p) => p.entries > 0);
|
||||
expect(point?.bucket).toBe("2026-06-16");
|
||||
});
|
||||
|
||||
it("series points are sorted and carry per-bucket entries/exits/revenue", async () => {
|
||||
await entry(at("2026-06-10T08:00:00Z"));
|
||||
await payment(at("2026-06-10T09:00:00Z"), 7000);
|
||||
await entry(at("2026-06-12T08:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
const labels = r.series.map((p) => p.bucket);
|
||||
expect(labels).toEqual([...labels].sort());
|
||||
const d10 = r.series.find((p) => p.bucket === "2026-06-10");
|
||||
expect(d10?.entries).toBe(1);
|
||||
expect(d10?.revenueMinor).toBe(7000);
|
||||
});
|
||||
|
||||
it("entriesByHour is a 24-slot local-hour histogram", async () => {
|
||||
// 06:00Z = 08:00 local (summer) → hour slot 8.
|
||||
await entry(at("2026-06-10T06:00:00Z"));
|
||||
await entry(at("2026-06-11T06:00:00Z"));
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.entriesByHour).toHaveLength(24);
|
||||
expect(r.entriesByHour[8]).toBe(2);
|
||||
expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
||||
it("computes parked-minute stats from closed sessions whose exit fell in range", async () => {
|
||||
// 60-min and 120-min stays → avg 90, median 90.
|
||||
db.insert(sessions).values({
|
||||
id: "s1",
|
||||
identity: "t1",
|
||||
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||
exitedAt: at("2026-06-10T09:00:00Z"),
|
||||
state: "closed",
|
||||
}).run();
|
||||
db.insert(sessions).values({
|
||||
id: "s2",
|
||||
identity: "t2",
|
||||
enteredAt: at("2026-06-10T08:00:00Z"),
|
||||
exitedAt: at("2026-06-10T10:00:00Z"),
|
||||
state: "closed",
|
||||
}).run();
|
||||
// An OPEN session (no exit) must not count.
|
||||
db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run();
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.totals.closedSessions).toBe(2);
|
||||
expect(r.totals.totalParkedMinutes).toBe(180);
|
||||
expect(r.totals.avgParkedMinutes).toBe(90);
|
||||
expect(r.totals.medianParkedMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("counts subscriptions by status and currently-valid coverage as of `to`", async () => {
|
||||
const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") };
|
||||
// active + valid window covering `to`, quantity 2.
|
||||
db.insert(subscriptions).values({
|
||||
id: "a", status: "active", quantity: 2,
|
||||
validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base,
|
||||
}).run();
|
||||
// active but EXPIRED before `to` → not currently valid.
|
||||
db.insert(subscriptions).values({
|
||||
id: "b", status: "active", quantity: 1,
|
||||
validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base,
|
||||
}).run();
|
||||
// suspended.
|
||||
db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run();
|
||||
|
||||
const r = reportSummary(db, { ...RANGE, bucket: "day" });
|
||||
expect(r.subscriptions.active).toBe(2);
|
||||
expect(r.subscriptions.suspended).toBe(1);
|
||||
expect(r.subscriptions.revoked).toBe(0);
|
||||
expect(r.subscriptions.currentlyValid).toBe(1);
|
||||
expect(r.subscriptions.coveredCars).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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") {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Db } from "@parking/db";
|
||||
import { requirePermission } from "../auth.js";
|
||||
import { reportSummary, type Bucket } from "../reports.js";
|
||||
|
||||
// Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions
|
||||
// cache for durations); no writes, no new event types. Gated on `report:read` — the
|
||||
// same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md.
|
||||
|
||||
const BUCKETS: Bucket[] = ["hour", "day", "month"];
|
||||
|
||||
/** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */
|
||||
function parseQuery(q: { from?: string; to?: string; bucket?: string }): {
|
||||
from: string;
|
||||
to: string;
|
||||
bucket: Bucket;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString();
|
||||
const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString();
|
||||
const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day";
|
||||
// Guard the inversion (from after to) — swap rather than return an empty report.
|
||||
return from <= to ? { from, to, bucket } : { from: to, to: from, bucket };
|
||||
}
|
||||
|
||||
function isFiniteIso(s: string | undefined): boolean {
|
||||
return !!s && Number.isFinite(Date.parse(s));
|
||||
}
|
||||
|
||||
export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const guard = requirePermission("report:read");
|
||||
|
||||
// The whole dashboard in one call: totals, the time series, peak-hour histogram, and
|
||||
// subscription stats — aggregated server-side so the SPA just renders. Bucketed in the
|
||||
// site timezone. See reports.ts.
|
||||
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||
"/api/reports/summary",
|
||||
{ preHandler: guard },
|
||||
async (req) => reportSummary(db, parseQuery(req.query)),
|
||||
);
|
||||
|
||||
// The same series as CSV (one row per bucket) for spreadsheet / accountant export.
|
||||
// Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike
|
||||
// the JSON which stays in minor units. text/csv with a download filename.
|
||||
app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>(
|
||||
"/api/reports/summary.csv",
|
||||
{ preHandler: guard },
|
||||
async (req, reply) => {
|
||||
const summary = reportSummary(db, parseQuery(req.query));
|
||||
const lines = [
|
||||
"bucket,entries,exits,payments,revenue",
|
||||
...summary.series.map((p) =>
|
||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
||||
),
|
||||
];
|
||||
reply
|
||||
.header("content-type", "text/csv; charset=utf-8")
|
||||
.header(
|
||||
"content-disposition",
|
||||
`attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`,
|
||||
)
|
||||
.send(lines.join("\n") + "\n");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { userRoutes } from "./routes/users.js";
|
||||
import { roleRoutes } from "./routes/roles.js";
|
||||
import { deviceRoutes } from "./routes/devices.js";
|
||||
import { eventRoutes } from "./routes/events.js";
|
||||
import { reportRoutes } from "./routes/reports.js";
|
||||
import { payRoutes } from "./routes/pay.js";
|
||||
import { subscriptionRoutes } from "./routes/subscriptions.js";
|
||||
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
||||
@@ -141,6 +142,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
);
|
||||
await eventRoutes(app, db, eventLog);
|
||||
|
||||
// Admin reporting: read-only charts/totals aggregated from the signed ledger
|
||||
// (+ sessions cache for durations). Gated on report:read. See routes/reports.ts.
|
||||
await reportRoutes(app, db);
|
||||
|
||||
// Live booth feed: server-pushed ledger + occupancy + printer-status over a
|
||||
// single authenticated WebSocket (/api/ws). See routes/ws.ts.
|
||||
await wsRoutes(app, db, deviceMonitor);
|
||||
|
||||
Reference in New Issue
Block a user