Files
parking_solution/apps/server/src/reports.test.ts
T
julian 7ef332999e feat(reports): occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
The dashboard had generic BI views but nothing parking-shaped. Added:

- Occupancy step-area over the range with the configured capacity as a
  red reference line. occupancyStart folds the ENTIRE prior ledger
  (voided entries excluded, clamped ≥0); each series point carries
  occupancyEnd. Answers "when are we near full".
- Entries heatmap hour × day-of-week (7×24, row 0 = Monday, site tz) as
  a pure CSS-grid intensity map — weekday-vs-weekend at a glance, the
  direct evidence for tariff windows. Replaces the flat hour histogram
  (strictly contains it).
- Stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h/
  tail): where ladder/up-to breakpoints should sit.
- Voids + anomalies KPIs (accented when >0) — the look-closer counters
  the signed chain exists for; peak-occupancy KPI (peak / capacity).
- Revenue bars stacked cash vs card (the drawer's money vs the bank's);
  CSV export gains cash, card, occupancy_end columns.

Internals: localParts caches its Intl formatter per tz (was one new
formatter per ledger row); @parking/db re-exports lt/gt. 5 new tests.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-06 12:35:54 +02:00

249 lines
11 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 { 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);
});
});
describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => {
it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => {
// Before the range: 3 entries, 1 exit → 2 cars inside when June opens.
await entry(at("2026-05-20T08:00:00Z"));
await entry(at("2026-05-20T09:00:00Z"));
await entry(at("2026-05-21T10:00:00Z"));
await exit(at("2026-05-21T12:00:00Z"));
// In range: +2 on the 10th, −1 on the 11th.
await entry(at("2026-06-10T08:00:00Z"));
await entry(at("2026-06-10T09:00:00Z"));
await exit(at("2026-06-11T09:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(2);
expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([
["2026-06-10", 4],
["2026-06-11", 3],
]);
});
it("a voided pre-range entry does not inflate occupancyStart", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.occupancyStart).toBe(0);
});
it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => {
// 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June).
await entry(at("2026-06-10T08:00:00Z"));
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column
expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1);
});
it("stay histogram buckets closed sessions; series carries the cash/card split", async () => {
db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30
db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120
db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail
await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" });
await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count]));
expect(counts["30"]).toBe(1);
expect(counts["120"]).toBe(1);
expect(counts["null"]).toBe(1);
const day = r.series.find((p) => p.bucket === "2026-06-10")!;
expect(day.cashMinor).toBe(500);
expect(day.cardMinor).toBe(700);
});
it("counts voids and anomalies in range (the look-closer counters)", async () => {
const id = randomUUID();
await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") });
await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } });
await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } });
const r = reportSummary(db, { ...RANGE, bucket: "day" });
expect(r.totals.voids).toBe(1);
expect(r.totals.anomalies).toBe(1);
expect(r.totals.entries).toBe(0); // the voided entry stays excluded
});
});