Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9887c2a76 | |||
| 7649b897c4 | |||
| ab968eb25e | |||
| 5e1a885dcb | |||
| 6cf3492bff | |||
| ffe8c13a1c | |||
| fcea992e1e | |||
| 81bc2e357c | |||
| 7ef332999e | |||
| a2e102f3dd |
@@ -16,7 +16,7 @@ import { createRequire } from "node:module";
|
|||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const { createDb, users, eq } = require("@parking/db");
|
const { createDb, users, roles, eq } = require("@parking/db");
|
||||||
|
|
||||||
const DEFAULT_USERNAME = "admin";
|
const DEFAULT_USERNAME = "admin";
|
||||||
|
|
||||||
@@ -53,6 +53,14 @@ if (!password || password.length < 8) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
|
// Self-heal the built-in `admin` ROLE row. Migration 0007 seeds it once, but the
|
||||||
|
// training reset (reset-db.mjs --users/--all) wipes the roles table and points here
|
||||||
|
// to re-seed — without this, the user insert dies on the role_id FOREIGN KEY (field
|
||||||
|
// failure 2026-07-06). The admin permission SET is resolved in code (auth.ts), so
|
||||||
|
// the row alone is all the FK needs.
|
||||||
|
await db.insert(roles).values({ id: "admin", name: "Admin", builtin: 1 }).onConflictDoNothing();
|
||||||
|
|
||||||
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
const existing = await db.select().from(users).where(eq(users.username, username)).get();
|
||||||
if (existing && process.env.FORCE !== "1") {
|
if (existing && process.env.FORCE !== "1") {
|
||||||
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
console.error(`user "${username}" already exists (set FORCE=1 to reset the password)`);
|
||||||
@@ -73,4 +81,30 @@ if (existing) {
|
|||||||
});
|
});
|
||||||
console.log(`created admin "${username}"`);
|
console.log(`created admin "${username}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the action into the SIGNED ledger (config_change). A console seed/reset is
|
||||||
|
// a Linux-admin action the app can't gate — but it must stay ATTRIBUTABLE after the
|
||||||
|
// fact (the chain is the audit record; whoever holds root can reset a password, they
|
||||||
|
// can't do it silently). Uses the server's own compiled EventLog + signer from dist/
|
||||||
|
// (present in the container; in a dev checkout run `pnpm build` first). Best-effort:
|
||||||
|
// a missing build or signing key WARNS loudly but never blocks the seed — locking an
|
||||||
|
// admin out to protect an audit line would invert the priority.
|
||||||
|
try {
|
||||||
|
const { EventLog } = await import("../dist/event-log.js");
|
||||||
|
const { buildSigner } = await import("../dist/signer.js");
|
||||||
|
const log = new EventLog(db, buildSigner());
|
||||||
|
await log.append({
|
||||||
|
type: "config_change",
|
||||||
|
source: "manual",
|
||||||
|
identity: `user:${username}`,
|
||||||
|
payload: {
|
||||||
|
setting: existing ? "admin.passwordReset" : "admin.seeded",
|
||||||
|
username,
|
||||||
|
operator: "console:seed-admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("recorded to the signed ledger (config_change)");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`WARNING: NOT recorded to the signed ledger: ${err.message}`);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -181,3 +181,68 @@ describe("reportSummary — duration (sessions cache) + subscriptions", () => {
|
|||||||
expect(r.subscriptions.coveredCars).toBe(2);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
gte,
|
||||||
|
lt,
|
||||||
lte,
|
lte,
|
||||||
ledgerEvents,
|
ledgerEvents,
|
||||||
sessions,
|
sessions,
|
||||||
|
siteConfig,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
tariffVersions,
|
tariffVersions,
|
||||||
tariffs,
|
tariffs,
|
||||||
@@ -46,8 +48,13 @@ export interface SeriesPoint {
|
|||||||
readonly exits: number;
|
readonly exits: number;
|
||||||
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
/** Net transient revenue collected in the bucket (minor units), all tenders. */
|
||||||
readonly revenueMinor: number;
|
readonly revenueMinor: number;
|
||||||
|
/** Tender split of the bucket's revenue (cash = everything not card). */
|
||||||
|
readonly cashMinor: number;
|
||||||
|
readonly cardMinor: number;
|
||||||
/** Payment COUNT in the bucket (transactions, not amount). */
|
/** Payment COUNT in the bucket (transactions, not amount). */
|
||||||
readonly payments: number;
|
readonly payments: number;
|
||||||
|
/** Cars inside at the END of the bucket (occupancyStart + running entries−exits). */
|
||||||
|
readonly occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -67,6 +74,10 @@ export interface ReportTotals {
|
|||||||
readonly totalParkedMinutes: number;
|
readonly totalParkedMinutes: number;
|
||||||
readonly avgParkedMinutes: number;
|
readonly avgParkedMinutes: number;
|
||||||
readonly medianParkedMinutes: number;
|
readonly medianParkedMinutes: number;
|
||||||
|
/** Cancelled tickets + signed anomalies in range — the "look closer" counters
|
||||||
|
* (the operator at the booth is the threat model's primary adversary). */
|
||||||
|
readonly voids: number;
|
||||||
|
readonly anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStats {
|
export interface SubscriptionStats {
|
||||||
@@ -79,6 +90,13 @@ export interface SubscriptionStats {
|
|||||||
readonly coveredCars: number;
|
readonly coveredCars: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One bar of the stay-duration histogram: stays up to `uptoMin` minutes (null = the
|
||||||
|
* open-ended tail). Edges chosen to mirror how tariffs are designed (see tariff.md). */
|
||||||
|
export interface StayBucket {
|
||||||
|
readonly uptoMin: number | null;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportSummary {
|
export interface ReportSummary {
|
||||||
readonly from: string;
|
readonly from: string;
|
||||||
readonly to: string;
|
readonly to: string;
|
||||||
@@ -89,25 +107,43 @@ export interface ReportSummary {
|
|||||||
readonly series: SeriesPoint[];
|
readonly series: SeriesPoint[];
|
||||||
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
/** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */
|
||||||
readonly entriesByHour: number[];
|
readonly entriesByHour: number[];
|
||||||
|
/** Entries by [day-of-week][hour-of-day] — 7×24, row 0 = Monday. The heatmap that
|
||||||
|
* shows weekday-vs-weekend patterns (feeds tariff-window design). */
|
||||||
|
readonly entriesByDowHour: number[][];
|
||||||
|
/** Stay-duration histogram over closed sessions in range. */
|
||||||
|
readonly stayHistogram: StayBucket[];
|
||||||
|
/** Cars inside when the range OPENS (folded from the whole prior ledger). */
|
||||||
|
readonly occupancyStart: number;
|
||||||
|
/** Nominal capacity from site config (null = uncapped) — the reference line. */
|
||||||
|
readonly capacity: number | null;
|
||||||
readonly subscriptions: SubscriptionStats;
|
readonly subscriptions: SubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */
|
/** 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 fmtCache = new Map<string, Intl.DateTimeFormat>();
|
||||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
const DOW_INDEX: Record<string, number> = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 };
|
||||||
|
function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number; dow: number } {
|
||||||
|
// Cached per tz — this runs once per ledger row in a report.
|
||||||
|
let fmt = fmtCache.get(tz);
|
||||||
|
if (!fmt) {
|
||||||
|
fmt = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: tz,
|
timeZone: tz,
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
hourCycle: "h23",
|
hourCycle: "h23",
|
||||||
|
weekday: "short",
|
||||||
});
|
});
|
||||||
|
fmtCache.set(tz, fmt);
|
||||||
|
}
|
||||||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value]));
|
||||||
return {
|
return {
|
||||||
y: Number(parts.year),
|
y: Number(parts.year),
|
||||||
mo: Number(parts.month),
|
mo: Number(parts.month),
|
||||||
d: Number(parts.day),
|
d: Number(parts.day),
|
||||||
h: Number(parts.hour),
|
h: Number(parts.hour),
|
||||||
|
dow: DOW_INDEX[parts.weekday ?? ""] ?? 0, // row 0 = Monday
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +198,7 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const seriesMap = new Map<string, SeriesPoint>();
|
const seriesMap = new Map<string, SeriesPoint>();
|
||||||
const entriesByHour = new Array<number>(24).fill(0);
|
const entriesByHour = new Array<number>(24).fill(0);
|
||||||
|
const entriesByDowHour = Array.from({ length: 7 }, () => new Array<number>(24).fill(0));
|
||||||
const totals = {
|
const totals = {
|
||||||
entries: 0,
|
entries: 0,
|
||||||
exits: 0,
|
exits: 0,
|
||||||
@@ -172,12 +209,14 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
ticketMinor: 0,
|
ticketMinor: 0,
|
||||||
subscriptionSalesMinor: 0,
|
subscriptionSalesMinor: 0,
|
||||||
subscriptionWindowMinor: 0,
|
subscriptionWindowMinor: 0,
|
||||||
|
voids: 0,
|
||||||
|
anomalies: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function point(label: string): SeriesPoint {
|
function point(label: string): SeriesPoint {
|
||||||
let p = seriesMap.get(label);
|
let p = seriesMap.get(label);
|
||||||
if (!p) {
|
if (!p) {
|
||||||
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 };
|
p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, payments: 0, occupancyEnd: 0 };
|
||||||
seriesMap.set(label, p);
|
seriesMap.set(label, p);
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
@@ -196,11 +235,16 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const lp = localParts(row.occurredAt, tz);
|
||||||
entriesByHour[h] = (entriesByHour[h] ?? 0) + 1;
|
entriesByHour[lp.h] = (entriesByHour[lp.h] ?? 0) + 1;
|
||||||
|
entriesByDowHour[lp.dow]![lp.h] = (entriesByDowHour[lp.dow]![lp.h] ?? 0) + 1;
|
||||||
} else if (row.type === "vehicle_exit") {
|
} else if (row.type === "vehicle_exit") {
|
||||||
totals.exits++;
|
totals.exits++;
|
||||||
p.exits++;
|
p.exits++;
|
||||||
|
} else if (row.type === "void") {
|
||||||
|
totals.voids++;
|
||||||
|
} else if (row.type === "anomaly") {
|
||||||
|
totals.anomalies++;
|
||||||
} else if (row.type === "payment") {
|
} else if (row.type === "payment") {
|
||||||
const pl = (row.payload ?? {}) as PaymentPayload;
|
const pl = (row.payload ?? {}) as PaymentPayload;
|
||||||
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
|
||||||
@@ -209,8 +253,13 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
totals.revenueMinor += amt;
|
totals.revenueMinor += amt;
|
||||||
p.payments++;
|
p.payments++;
|
||||||
p.revenueMinor += amt;
|
p.revenueMinor += amt;
|
||||||
if (pl.tender === "card") totals.cardMinor += amt;
|
if (pl.tender === "card") {
|
||||||
else totals.cashMinor += amt;
|
totals.cardMinor += amt;
|
||||||
|
p.cardMinor += amt;
|
||||||
|
} else {
|
||||||
|
totals.cashMinor += amt;
|
||||||
|
p.cashMinor += amt;
|
||||||
|
}
|
||||||
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
// Revenue split mirrors the shift Z-report: subscription sale / window charge /
|
||||||
// (the rest is) transient ticket revenue.
|
// (the rest is) transient ticket revenue.
|
||||||
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt;
|
||||||
@@ -221,6 +270,31 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
|
|
||||||
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket));
|
||||||
|
|
||||||
|
// --- Occupancy: fold the PRIOR ledger for cars-inside at range start, then walk the
|
||||||
|
// series. Voided pre-range entries cancel out the same way the in-range pass does.
|
||||||
|
// Sparse buckets (no events) simply carry the previous level — the step line is exact
|
||||||
|
// at every plotted point.
|
||||||
|
const prior = db
|
||||||
|
.select({ type: ledgerEvents.type, identity: ledgerEvents.identity })
|
||||||
|
.from(ledgerEvents)
|
||||||
|
.where(lt(ledgerEvents.occurredAt, q.from))
|
||||||
|
.all();
|
||||||
|
const priorVoided = new Set<string>();
|
||||||
|
for (const r of prior) if (r.type === "void" && r.identity) priorVoided.add(r.identity);
|
||||||
|
let occupancyStart = 0;
|
||||||
|
for (const r of prior) {
|
||||||
|
if (r.type === "vehicle_entry" && !(r.identity && priorVoided.has(r.identity))) occupancyStart++;
|
||||||
|
else if (r.type === "vehicle_exit") occupancyStart--;
|
||||||
|
}
|
||||||
|
occupancyStart = Math.max(0, occupancyStart);
|
||||||
|
let running = occupancyStart;
|
||||||
|
for (const p of series) {
|
||||||
|
running = Math.max(0, running + p.entries - p.exits);
|
||||||
|
(p as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }).occupancyEnd = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacity = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get()?.capacity ?? null;
|
||||||
|
|
||||||
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
// No payment in range? Fall back to the site tariff's latest version currency, so a
|
||||||
// zero-revenue range still labels its money column.
|
// zero-revenue range still labels its money column.
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
@@ -251,6 +325,19 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
durations.sort((a, b) => a - b);
|
durations.sort((a, b) => a - b);
|
||||||
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
const totalParkedMinutes = durations.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Stay-duration histogram. Edges mirror how rate cards are designed (30m/1h bands,
|
||||||
|
// the 8h working day, the 24h rolling day) so the chart answers "where should the
|
||||||
|
// ladder/up-to breakpoints sit". Last bucket is the open-ended >24h tail.
|
||||||
|
const STAY_EDGES_MIN = [30, 60, 120, 240, 480, 1440];
|
||||||
|
const stayHistogram: { uptoMin: number | null; count: number }[] = [
|
||||||
|
...STAY_EDGES_MIN.map((uptoMin) => ({ uptoMin, count: 0 })),
|
||||||
|
{ uptoMin: null, count: 0 },
|
||||||
|
];
|
||||||
|
for (const mins of durations) {
|
||||||
|
const i = STAY_EDGES_MIN.findIndex((edge) => mins <= edge);
|
||||||
|
stayHistogram[i === -1 ? STAY_EDGES_MIN.length : i]!.count++;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
// --- Subscriptions: status counts + currently-valid (window covers `to`).
|
||||||
const subs = db.select().from(subscriptions).all();
|
const subs = db.select().from(subscriptions).all();
|
||||||
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 };
|
||||||
@@ -283,6 +370,10 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
},
|
},
|
||||||
series,
|
series,
|
||||||
entriesByHour,
|
entriesByHour,
|
||||||
|
entriesByDowHour,
|
||||||
|
stayHistogram,
|
||||||
|
occupancyStart,
|
||||||
|
capacity,
|
||||||
subscriptions: subStats,
|
subscriptions: subStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,18 @@ export async function reportRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const summary = reportSummary(db, parseQuery(req.query));
|
const summary = reportSummary(db, parseQuery(req.query));
|
||||||
const lines = [
|
const lines = [
|
||||||
"bucket,entries,exits,payments,revenue",
|
"bucket,entries,exits,payments,revenue,cash,card,occupancy_end",
|
||||||
...summary.series.map((p) =>
|
...summary.series.map((p) =>
|
||||||
[p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2)].join(","),
|
[
|
||||||
|
p.bucket,
|
||||||
|
p.entries,
|
||||||
|
p.exits,
|
||||||
|
p.payments,
|
||||||
|
(p.revenueMinor / 100).toFixed(2),
|
||||||
|
(p.cashMinor / 100).toFixed(2),
|
||||||
|
(p.cardMinor / 100).toFixed(2),
|
||||||
|
p.occupancyEnd,
|
||||||
|
].join(","),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
reply
|
reply
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// HOW the amount is produced — the same engine walk with a trace collector
|
||||||
|
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||||
|
const breakdown = pricing.withinGrace
|
||||||
|
? null
|
||||||
|
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||||
|
|
||||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
const enteredMs = Date.parse(b.enteredAt);
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
@@ -196,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
|||||||
@@ -691,14 +691,14 @@ export class ShiftService {
|
|||||||
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
// Abonime is the subscription TOTAL; only the out-of-window part is broken out.
|
||||||
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
// (subscriptionSalesMinor stays in the signed payload — it's just not printed.)
|
||||||
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
`Abonime: ${money(r.subscriptionTotalMinor)} ${cur}`,
|
||||||
` jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
`Jashtë orarit: ${money(r.subscriptionWindowMinor)} ${cur}`,
|
||||||
"",
|
"",
|
||||||
"-- Arka --",
|
"-- Arka --",
|
||||||
`Fillimi: ${money(r.openingFloatMinor)} ${cur}`,
|
`Gjëndje fillestare: ${money(r.openingFloatMinor)} ${cur}`,
|
||||||
`Para të marra: ${money(r.cashTotalMinor)} ${cur}`,
|
`Para të grumbulluara: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Para të shtuara: ${money(r.cashAddedMinor)} ${cur}`,
|
`Arkëtime: ${money(r.cashAddedMinor)} ${cur}`,
|
||||||
`Para të hequra: ${money(r.cashRemovedMinor)} ${cur}`,
|
`Pagesa: ${money(r.cashRemovedMinor)} ${cur}`,
|
||||||
`Arka e pritur: ${money(r.expectedDrawerMinor)} ${cur}`,
|
`Gjëndje aktuale: ${money(r.expectedDrawerMinor)} ${cur}`,
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
await printer.printReport({ title: "RAPORT TURNI", lines });
|
await printer.printReport({ title: "RAPORT TURNI", lines });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type MovementStatus,
|
type MovementStatus,
|
||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { Panel } from "./ui/Panel.js";
|
import { Panel } from "./ui/Panel.js";
|
||||||
import type { LedgerEvent } from "@parking/shared";
|
import type { LedgerEvent } from "@parking/shared";
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
|
|||||||
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string; voucherNo?: string; reason?: string };
|
||||||
const amt = pl.amountMinor ?? 0;
|
const amt = pl.amountMinor ?? 0;
|
||||||
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
|
||||||
const time = new Date(e.occurredAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
const time = formatClock(e.occurredAt);
|
||||||
const label =
|
const label =
|
||||||
e.type === "payment"
|
e.type === "payment"
|
||||||
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
|
||||||
|
|||||||
+95
-12
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
Pie,
|
Pie,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -37,6 +40,7 @@ const C = {
|
|||||||
border: "#2a2f38",
|
border: "#2a2f38",
|
||||||
text: "#f2f2ee",
|
text: "#f2f2ee",
|
||||||
panel: "#14171c",
|
panel: "#14171c",
|
||||||
|
panel2: "#1e222a",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresetKey = "today" | "7d" | "30d" | "90d";
|
type PresetKey = "today" | "7d" | "30d" | "90d";
|
||||||
@@ -140,27 +144,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
...p,
|
...p,
|
||||||
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
label: data.bucket === "hour" ? p.bucket.slice(11) + "h" : p.bucket,
|
||||||
revenue: p.revenueMinor / 100,
|
revenue: p.revenueMinor / 100,
|
||||||
|
cash: p.cashMinor / 100,
|
||||||
|
card: p.cardMinor / 100,
|
||||||
}));
|
}));
|
||||||
const hours = data.entriesByHour.map((entries, h) => ({ hour: `${h}`, entries }));
|
|
||||||
const mix = [
|
const mix = [
|
||||||
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
{ name: t("reports.mix.ticket"), value: tot.ticketMinor, color: C.amber },
|
||||||
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
{ name: t("reports.mix.subSales"), value: tot.subscriptionSalesMinor, color: C.cyan },
|
||||||
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
{ name: t("reports.mix.subWindow"), value: tot.subscriptionWindowMinor, color: C.green },
|
||||||
].filter((s) => s.value > 0);
|
].filter((s) => s.value > 0);
|
||||||
|
const peakOcc = Math.max(data.occupancyStart, ...data.series.map((p) => p.occupancyEnd));
|
||||||
|
// Stay-duration bars: "≤30m … ≤24h" + the open-ended tail.
|
||||||
|
const stay = data.stayHistogram.map((b) => ({
|
||||||
|
label: b.uptoMin == null ? `>24${t("reports.stay.h")}` : b.uptoMin < 60 ? `≤${b.uptoMin}${t("reports.stay.m")}` : `≤${b.uptoMin / 60}${t("reports.stay.h")}`,
|
||||||
|
count: b.count,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* KPI cards. */}
|
{/* KPI cards. */}
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4 xl:grid-cols-8">
|
||||||
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
<Kpi label={t("reports.kpi.entries")} value={String(tot.entries)} accent="green" />
|
||||||
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
<Kpi label={t("reports.kpi.exits")} value={String(tot.exits)} accent="red" />
|
||||||
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
<Kpi label={t("reports.kpi.revenue")} value={money(tot.revenueMinor)} accent="amber" />
|
||||||
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
<Kpi label={t("reports.kpi.payments")} value={String(tot.payments)} accent="cyan" />
|
||||||
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
<Kpi label={t("reports.kpi.avgStay")} value={formatMinutes(tot.avgParkedMinutes)} />
|
||||||
<Kpi
|
<Kpi
|
||||||
label={t("reports.kpi.subscribers")}
|
label={t("reports.kpi.peakOcc")}
|
||||||
value={String(data.subscriptions.currentlyValid)}
|
value={data.capacity ? `${peakOcc} / ${data.capacity}` : String(peakOcc)}
|
||||||
/>
|
/>
|
||||||
|
{/* The "look closer" counters — a spike here is what the signed chain is FOR. */}
|
||||||
|
<Kpi label={t("reports.kpi.voids")} value={String(tot.voids)} accent={tot.voids > 0 ? "amber" : undefined} />
|
||||||
|
<Kpi label={t("reports.kpi.anomalies")} value={String(tot.anomalies)} accent={tot.anomalies > 0 ? "red" : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry / exit over time. */}
|
{/* Entry / exit over time. */}
|
||||||
@@ -192,8 +206,38 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Occupancy over time — THE parking curve: cars inside vs capacity. Step-shaped
|
||||||
|
(occupancy only moves at entries/exits); the red line is the configured cap. */}
|
||||||
|
<Panel title={t("reports.chart.occupancy")}>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<AreaChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
|
{data.capacity != null && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={data.capacity}
|
||||||
|
stroke={C.red}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: t("reports.capacityLine"), fill: C.red, fontSize: 11, position: "insideTopRight" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Area
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="occupancyEnd"
|
||||||
|
name={t("reports.chart.occupancySeries")}
|
||||||
|
stroke={C.cyan}
|
||||||
|
fill={C.cyan}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
{/* Revenue per bucket. */}
|
{/* Revenue per bucket, stacked by tender — the drawer's cash vs the bank's card. */}
|
||||||
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
<Panel title={t("reports.chart.revenue", { currency: cur })}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={series} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
@@ -201,7 +245,9 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} />
|
<YAxis stroke={C.muted} fontSize={11} />
|
||||||
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
<Tooltip contentStyle={tooltipStyle} formatter={(v) => money(Math.round(Number(v) * 100))} />
|
||||||
<Bar dataKey="revenue" name={t("reports.kpi.revenue")} fill={C.amber} />
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Bar dataKey="cash" stackId="tender" name={t("reports.row.cash")} fill={C.amber} />
|
||||||
|
<Bar dataKey="card" stackId="tender" name={t("reports.row.card")} fill={C.cyan} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -232,15 +278,15 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Peak hours (entries by hour-of-day). */}
|
{/* Stay-duration histogram — where the ladder/up-to breakpoints should sit. */}
|
||||||
<Panel title={t("reports.chart.peakHours")}>
|
<Panel title={t("reports.chart.stay")}>
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={hours} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
<BarChart data={stay} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
|
||||||
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
<CartesianGrid stroke={C.border} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="hour" stroke={C.muted} fontSize={11} interval={1} />
|
<XAxis dataKey="label" stroke={C.muted} fontSize={11} />
|
||||||
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
<YAxis stroke={C.muted} fontSize={11} allowDecimals={false} />
|
||||||
<Tooltip contentStyle={tooltipStyle} />
|
<Tooltip contentStyle={tooltipStyle} />
|
||||||
<Bar dataKey="entries" name={t("reports.kpi.entries")} fill={C.cyan} />
|
<Bar dataKey="count" name={t("reports.row.closed")} fill={C.green} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -262,6 +308,12 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Entries heatmap: hour × day-of-week. Weekday-vs-weekend patterns at a glance —
|
||||||
|
the direct input for tariff windows (night rates, weekend cards, early bird). */}
|
||||||
|
<Panel title={t("reports.chart.heatmap")}>
|
||||||
|
<Heatmap matrix={data.entriesByDowHour} dows={t("reports.dowShort", { returnObjects: true }) as string[]} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<p className="text-[0.6875rem] text-term-muted">
|
<p className="text-[0.6875rem] text-term-muted">
|
||||||
{t("reports.footnote", { tz: data.tz })}
|
{t("reports.footnote", { tz: data.tz })}
|
||||||
</p>
|
</p>
|
||||||
@@ -269,6 +321,37 @@ function ReportBody({ data, t }: { data: ReportSummary; t: TFunction }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hour-of-day × day-of-week entries heatmap: pure CSS grid, amber intensity scaled to
|
||||||
|
* the busiest cell. Row 0 = Monday (server contract). Cell tooltip = exact count. */
|
||||||
|
function Heatmap({ matrix, dows }: { matrix: number[][]; dows: string[] }) {
|
||||||
|
const max = Math.max(1, ...matrix.flat());
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="grid min-w-[560px] grid-cols-[max-content_repeat(24,1fr)] gap-px text-[0.625rem]">
|
||||||
|
<span />
|
||||||
|
{Array.from({ length: 24 }, (_, h) => (
|
||||||
|
<span key={h} className="pb-0.5 text-center text-term-muted">
|
||||||
|
{h % 3 === 0 ? h : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{matrix.map((row, d) => (
|
||||||
|
<Fragment key={d}>
|
||||||
|
<span className="pr-1.5 leading-4 text-term-muted">{dows[d]}</span>
|
||||||
|
{row.map((v, h) => (
|
||||||
|
<span
|
||||||
|
key={h}
|
||||||
|
title={`${dows[d]} ${String(h).padStart(2, "0")}:00 — ${v}`}
|
||||||
|
className="h-4 rounded-[1px]"
|
||||||
|
style={{ background: v === 0 ? C.panel2 : C.amber, opacity: v === 0 ? 1 : 0.25 + 0.75 * (v / max) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const tooltipStyle = {
|
const tooltipStyle = {
|
||||||
background: C.panel,
|
background: C.panel,
|
||||||
border: `1px solid ${C.border}`,
|
border: `1px solid ${C.border}`,
|
||||||
|
|||||||
@@ -463,6 +463,16 @@ function DeviceSummary({ assignment, controllers }: { assignment: Assignment; co
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Printers don't bind to a barrier (routing is role + failoverRank) — show the
|
||||||
|
// role instead of a bogus "unbound" warning.
|
||||||
|
if (assignment.category === "printer") {
|
||||||
|
const role = typeof cfg.role === "string" ? cfg.role : null;
|
||||||
|
return role ? (
|
||||||
|
<span className="text-term-muted">
|
||||||
|
{t(role === "booth-receipt" ? "devices.role.booth" : "devices.role.lane")}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
// Bound device: show controller + relay it points at, with inherited direction.
|
// Bound device: show controller + relay it points at, with inherited direction.
|
||||||
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
const controllerId = typeof cfg.controllerId === "string" ? cfg.controllerId : null;
|
||||||
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
const relay = typeof cfg.relay === "number" ? cfg.relay : null;
|
||||||
@@ -686,7 +696,11 @@ function DeviceForm({
|
|||||||
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
...(i.role === "presence" && i.activeLow ? { activeLow: true } : {}),
|
||||||
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
...(i.role === "button" && i.cooldownSec ? { cooldownSec: i.cooldownSec } : {}),
|
||||||
}));
|
}));
|
||||||
} else if (controllerId && boundRelay !== "") {
|
} else if (!isPrinter && controllerId && boundRelay !== "") {
|
||||||
|
// Readers/cameras bind to a controller relay (which barrier a scan opens +
|
||||||
|
// inherited direction). Printers do NOT — routing is role+failoverRank only,
|
||||||
|
// so no binding is emitted (and a stale one saved before 2026-07-06 drops
|
||||||
|
// off on the next edit).
|
||||||
out.controllerId = controllerId;
|
out.controllerId = controllerId;
|
||||||
out.relay = boundRelay;
|
out.relay = boundRelay;
|
||||||
}
|
}
|
||||||
@@ -759,7 +773,9 @@ function DeviceForm({
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// Bound devices must point at a controller relay (binding is optional in the
|
// Bound devices must point at a controller relay (binding is optional in the
|
||||||
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
// model with a fallback, but the wizard guides the admin to bind explicitly).
|
||||||
if (!isController && (!controllerId || boundRelay === "")) {
|
// Printers are exempt: nothing consumes a printer's binding — their routing is
|
||||||
|
// role + failoverRank (see printer-routing.ts).
|
||||||
|
if (!isController && !isPrinter && (!controllerId || boundRelay === "")) {
|
||||||
setSaveError("Pick the controller and relay this device sits at.");
|
setSaveError("Pick the controller and relay this device sits at.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -959,8 +975,9 @@ function DeviceForm({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BOUND device: which controller + relay it sits at. */}
|
{/* BOUND device: which controller + relay it sits at. Not printers —
|
||||||
{!isController && (
|
nothing consumes a printer binding (role+rank routes print jobs). */}
|
||||||
|
{!isController && !isPrinter && (
|
||||||
<BindingPicker
|
<BindingPicker
|
||||||
controllers={controllers}
|
controllers={controllers}
|
||||||
controllerId={controllerId}
|
controllerId={controllerId}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type ShiftSummary,
|
type ShiftSummary,
|
||||||
type SessionUser,
|
type SessionUser,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { formatMoney, formatDuration, formatRelativeDateTime } from "./lib/format.js";
|
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { Spinner } from "./ui/Spinner.js";
|
import { Spinner } from "./ui/Spinner.js";
|
||||||
@@ -457,7 +457,7 @@ function TakingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
<p className="text-[0.75rem] text-term-muted">{t("common.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[0.8125rem] tabular-nums">
|
<div className="text-[0.8125rem] tabular-nums">
|
||||||
<div className="text-term-muted">{t("shift.asOf")} {new Date(x.asOf).toLocaleString()}</div>
|
<div className="text-term-muted">{t("shift.asOf")} {formatDateTime(x.asOf, t)}</div>
|
||||||
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
<div className="mt-1 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||||
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
<Figure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDateTime, type TFn } from "./lib/format.js";
|
||||||
|
|
||||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||||
@@ -179,9 +180,9 @@ function daysLabel(days: number[] | undefined, t: (k: string) => string): string
|
|||||||
|
|
||||||
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
/** A one-line label for a plan VERSION in the correction picker: effective date + its
|
||||||
* timeframe summary (or "24/7" when the version has no window). */
|
* timeframe summary (or "24/7" when the version has no window). */
|
||||||
function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string {
|
function versionLabel(v: SubscriptionPlan, t: TFn): string {
|
||||||
const eff = new Date(v.effectiveFrom);
|
const eff = new Date(v.effectiveFrom);
|
||||||
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString();
|
const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : formatDateTime(v.effectiveFrom, t);
|
||||||
const tf = v.timeframes;
|
const tf = v.timeframes;
|
||||||
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay");
|
||||||
return `${date} · ${rules}`;
|
return `${date} · ${rules}`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type SubscriptionPlan,
|
type SubscriptionPlan,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
|
import { formatDate } from "./lib/format.js";
|
||||||
import { currencyOptions } from "./lib/currencies.js";
|
import { currencyOptions } from "./lib/currencies.js";
|
||||||
|
|
||||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||||
@@ -277,7 +278,7 @@ export function SubscriptionPlansManager() {
|
|||||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||||
</span>
|
</span>
|
||||||
<span>{timeframesSummary(p.timeframes, t)}</span>
|
<span>{timeframesSummary(p.timeframes, t)}</span>
|
||||||
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
|
<span>{t("plans.colEffective")}: {formatDate(p.effectiveFrom, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Used by */}
|
{/* Used by */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
import { ApiError, fetchTariff, publishTariffVersion, type TariffState, type TariffVersion } from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
|
import { formatDateTime } from "./lib/format.js";
|
||||||
|
|
||||||
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
|
||||||
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
// creates a new IMMUTABLE version (the active card); old versions are kept so past
|
||||||
@@ -77,7 +78,7 @@ export function TariffComposer() {
|
|||||||
<p className="mb-4 text-[0.75rem] text-term-muted">
|
<p className="mb-4 text-[0.75rem] text-term-muted">
|
||||||
{state.active.name ? `${state.active.name} — ` : ""}
|
{state.active.name ? `${state.active.name} — ` : ""}
|
||||||
{t("tariff.activeSince", {
|
{t("tariff.activeSince", {
|
||||||
date: new Date(state.active.effectiveFrom).toLocaleString(),
|
date: formatDateTime(state.active.effectiveFrom, t),
|
||||||
count: state.versions.length,
|
count: state.versions.length,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
@@ -126,7 +127,7 @@ export function TariffComposer() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-semibold">
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
<span className="rounded border border-term-green px-1 text-[0.625rem] uppercase text-term-green">
|
||||||
{t("tariff.activeBadge")}
|
{t("tariff.activeBadge")}
|
||||||
@@ -134,7 +135,7 @@ export function TariffComposer() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -70,23 +70,35 @@ export interface FormState {
|
|||||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||||
|
|
||||||
function emptySteps(): StepForm[] {
|
/** Currency-plausible EXAMPLE amounts for fresh forms/rows. The old hardcoded
|
||||||
|
* "2.00 / 1.00" examples were euro-scaled — displayed under ALL they read as
|
||||||
|
* 2 lekë/hour, i.e. nonsense (operator feedback 2026-07-06). Lek amounts are
|
||||||
|
* ~100× the euro ones; USD rides with EUR. */
|
||||||
|
function examples(currency: string): { hi: string; lo: string; stepSmall: string; stepBig: string; lost: string } {
|
||||||
|
return currency.trim().toUpperCase() === "ALL"
|
||||||
|
? { hi: "200.00", lo: "100.00", stepSmall: "200.00", stepBig: "500.00", lost: "2000.00" }
|
||||||
|
: { hi: "2.00", lo: "1.00", stepSmall: "2.00", stepBig: "5.00", lost: "20.00" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySteps(currency: string): StepForm[] {
|
||||||
|
const ex = examples(currency);
|
||||||
return [
|
return [
|
||||||
{ hours: "1", total: "2.00" },
|
{ hours: "1", total: ex.stepSmall },
|
||||||
{ hours: "3", total: "5.00" },
|
{ hours: "3", total: ex.stepBig },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
function emptyLadder(): PricingForm {
|
function emptyLadder(currency: string): PricingForm {
|
||||||
|
const ex = examples(currency);
|
||||||
return {
|
return {
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
flat: "0.00",
|
flat: "0.00",
|
||||||
packageTotal: "0.00",
|
packageTotal: "0.00",
|
||||||
dailyCap: "",
|
dailyCap: "",
|
||||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
blocks: [{ hours: "1", price: ex.hi }, { hours: "", price: ex.lo }],
|
||||||
steps: emptySteps(),
|
steps: emptySteps(currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function emptyTier(): TierForm {
|
function emptyTier(currency: string): TierForm {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
priority: "10",
|
priority: "10",
|
||||||
@@ -96,18 +108,19 @@ function emptyTier(): TierForm {
|
|||||||
toHour: "",
|
toHour: "",
|
||||||
dateFrom: "",
|
dateFrom: "",
|
||||||
dateTo: "",
|
dateTo: "",
|
||||||
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
|
pricing: { ...emptyLadder(currency), blocks: [{ hours: "", price: examples(currency).lo }] },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyForm(): FormState {
|
export function emptyForm(): FormState {
|
||||||
|
const currency = "ALL"; // the site's currency — examples scale with it
|
||||||
return {
|
return {
|
||||||
currency: "ALL",
|
currency,
|
||||||
gracePeriodEntryMin: "15",
|
gracePeriodEntryMin: "15",
|
||||||
incrementMin: "60",
|
incrementMin: "60",
|
||||||
lostTicket: "20.00",
|
lostTicket: examples(currency).lost,
|
||||||
gracePeriodExitMin: "15",
|
gracePeriodExitMin: "15",
|
||||||
base: emptyLadder(),
|
base: emptyLadder(currency),
|
||||||
tiers: [],
|
tiers: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,31 +145,34 @@ function stepsToForm(steps: TariffStep[]): StepForm[] {
|
|||||||
|
|
||||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
|
||||||
// or window package).
|
// or window package).
|
||||||
function pricingFromCard(c: {
|
function pricingFromCard(
|
||||||
|
c: {
|
||||||
flatMinor?: number;
|
flatMinor?: number;
|
||||||
blocks?: TariffBlock[];
|
blocks?: TariffBlock[];
|
||||||
steps?: TariffStep[];
|
steps?: TariffStep[];
|
||||||
packageMinor?: number;
|
packageMinor?: number;
|
||||||
dailyCapMinor?: number | null;
|
dailyCapMinor?: number | null;
|
||||||
}): PricingForm {
|
},
|
||||||
|
currency: string,
|
||||||
|
): PricingForm {
|
||||||
if (c.steps != null && c.steps.length > 0) {
|
if (c.steps != null && c.steps.length > 0) {
|
||||||
return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
|
return { ...emptyLadder(currency), mode: "stepped", steps: stepsToForm(c.steps) };
|
||||||
}
|
}
|
||||||
if (c.packageMinor != null) {
|
if (c.packageMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
return { ...emptyLadder(currency), mode: "package", packageTotal: toMajor(c.packageMinor) };
|
||||||
}
|
}
|
||||||
if (c.flatMinor != null) {
|
if (c.flatMinor != null) {
|
||||||
return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
|
return { ...emptyLadder(currency), mode: "flat", flat: toMajor(c.flatMinor) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...emptyLadder(),
|
...emptyLadder(currency),
|
||||||
mode: "ladder",
|
mode: "ladder",
|
||||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||||
blocks: blocksToForm(c.blocks ?? []),
|
blocks: blocksToForm(c.blocks ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tierFromCard(c: TariffCard): TierForm {
|
function tierFromCard(c: TariffCard, currency: string): TierForm {
|
||||||
const w = c.window ?? {};
|
const w = c.window ?? {};
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -167,7 +183,7 @@ function tierFromCard(c: TariffCard): TierForm {
|
|||||||
toHour: w.toHour ?? "",
|
toHour: w.toHour ?? "",
|
||||||
dateFrom: w.dateFrom ?? "",
|
dateFrom: w.dateFrom ?? "",
|
||||||
dateTo: w.dateTo ?? "",
|
dateTo: w.dateTo ?? "",
|
||||||
pricing: pricingFromCard(c),
|
pricing: pricingFromCard(c, currency),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +198,14 @@ export function formFromVersion(currency: string, st: TariffStructure): FormStat
|
|||||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||||
};
|
};
|
||||||
if (isTariffV2(st)) {
|
if (isTariffV2(st)) {
|
||||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
return {
|
||||||
|
...common,
|
||||||
|
base: pricingFromCard(st.defaultCard, currency),
|
||||||
|
tiers: (st.windowedCards ?? []).map((c) => tierFromCard(c, currency)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// V1: the bare ladder becomes the default card body; no tiers.
|
// V1: the bare ladder becomes the default card body; no tiers.
|
||||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
return { ...common, base: pricingFromCard(st, currency), tiers: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formFromActive(s: TariffState): FormState {
|
export function formFromActive(s: TariffState): FormState {
|
||||||
@@ -278,6 +298,8 @@ export function TariffEditorForm({
|
|||||||
onChange: (update: (f: FormState) => FormState) => void;
|
onChange: (update: (f: FormState) => FormState) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
// The billing unit all flat/ladder prices are entered in (labels reflect it live).
|
||||||
|
const inc = Math.max(1, Math.round(Number(form.incrementMin)) || 60);
|
||||||
|
|
||||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
onChange((f) => ({ ...f, [key]: value }));
|
onChange((f) => ({ ...f, [key]: value }));
|
||||||
@@ -322,7 +344,7 @@ export function TariffEditorForm({
|
|||||||
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||||
}
|
}
|
||||||
function addTier() {
|
function addTier() {
|
||||||
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier(f.currency)] }));
|
||||||
}
|
}
|
||||||
function removeTier(i: number) {
|
function removeTier(i: number) {
|
||||||
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||||
@@ -355,6 +377,15 @@ export function TariffEditorForm({
|
|||||||
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The increment is the UNIT every flat/ladder price is charged in. At 60 the
|
||||||
|
form reads naturally as per-hour; any other value silently redefines every
|
||||||
|
price below, so shout it (the 60→10 "six charges per hour" trap). */}
|
||||||
|
{inc !== 60 && (
|
||||||
|
<p className="mt-2 rounded-term border border-term-amber/50 bg-term-amber/10 px-3 py-2 text-[0.75rem] text-term-amber">
|
||||||
|
{t("tariff.incrementWarning", { min: inc })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||||
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
|
||||||
@@ -363,6 +394,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={form.base}
|
pricing={form.base}
|
||||||
|
incrementMin={inc}
|
||||||
allowStepped
|
allowStepped
|
||||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||||
@@ -434,6 +466,7 @@ export function TariffEditorForm({
|
|||||||
<PricingEditor
|
<PricingEditor
|
||||||
t={t}
|
t={t}
|
||||||
pricing={tr.pricing}
|
pricing={tr.pricing}
|
||||||
|
incrementMin={inc}
|
||||||
allowPackage
|
allowPackage
|
||||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||||
@@ -459,8 +492,11 @@ export function TariffEditorForm({
|
|||||||
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
// (the default card); the package mode only where `allowPackage` (tier cards — the
|
||||||
// engine needs a window to be an occurrence of).
|
// engine needs a window to be an occurrence of).
|
||||||
function PricingEditor(props: {
|
function PricingEditor(props: {
|
||||||
t: (k: string) => string;
|
t: (k: string, opts?: Record<string, unknown>) => string;
|
||||||
pricing: PricingForm;
|
pricing: PricingForm;
|
||||||
|
/** Current billing increment (minutes) — every flat/ladder price is PER this unit,
|
||||||
|
* so the price labels state it explicitly instead of a vague "per increment". */
|
||||||
|
incrementMin: number;
|
||||||
allowStepped?: boolean;
|
allowStepped?: boolean;
|
||||||
allowPackage?: boolean;
|
allowPackage?: boolean;
|
||||||
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
|
||||||
@@ -475,6 +511,18 @@ function PricingEditor(props: {
|
|||||||
onRemoveStep?: (i: number) => void;
|
onRemoveStep?: (i: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, pricing: p } = props;
|
const { t, pricing: p } = props;
|
||||||
|
/** "= N / orë" equivalence for a per-increment price (only shown when the tick
|
||||||
|
* isn't an hour — at 60 the price already IS the hourly price). */
|
||||||
|
const perHour = (major: string): string | null => {
|
||||||
|
if (props.incrementMin === 60) return null;
|
||||||
|
const v = Number(major);
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return null;
|
||||||
|
return t("tariff.perHourEquiv", { amount: ((v * 60) / props.incrementMin).toFixed(2) });
|
||||||
|
};
|
||||||
|
const unitLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.pricePerHour") : t("tariff.pricePerN", { min: props.incrementMin });
|
||||||
|
const flatLabel =
|
||||||
|
props.incrementMin === 60 ? t("tariff.modeFlat") : t("tariff.modeFlatN", { min: props.incrementMin });
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
|
||||||
@@ -484,7 +532,7 @@ function PricingEditor(props: {
|
|||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||||
{t("tariff.modeFlat")}
|
{flatLabel}
|
||||||
</label>
|
</label>
|
||||||
{props.allowStepped && (
|
{props.allowStepped && (
|
||||||
<label className="inline-flex items-center gap-1.5 text-term-text">
|
<label className="inline-flex items-center gap-1.5 text-term-text">
|
||||||
@@ -550,8 +598,9 @@ function PricingEditor(props: {
|
|||||||
</>
|
</>
|
||||||
) : p.mode === "flat" ? (
|
) : p.mode === "flat" ? (
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
<span className="label">{unitLabel}</span>
|
||||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||||
|
{perHour(p.flat) && <span className="text-[0.6875rem] text-term-muted">{perHour(p.flat)}</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -559,7 +608,7 @@ function PricingEditor(props: {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
|
||||||
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
|
<th className="label px-2 pb-1 font-normal">{unitLabel}</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -579,7 +628,10 @@ function PricingEditor(props: {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
|
||||||
|
{perHour(b.price) && <span className="text-[0.6875rem] text-term-muted">{perHour(b.price)}</span>}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2">
|
<td className="px-2">
|
||||||
{!isTail && (
|
{!isTail && (
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
import type { FeeBreakdown } from "@parking/shared";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||||
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
@@ -202,7 +204,7 @@ export function TariffLab() {
|
|||||||
{selectedDraft
|
{selectedDraft
|
||||||
? selectedDraft.name
|
? selectedDraft.name
|
||||||
: selectedVersion
|
: selectedVersion
|
||||||
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||||
: t("lab.activeTariff")}
|
: t("lab.activeTariff")}
|
||||||
</span>
|
</span>
|
||||||
{selectedDraft && (
|
{selectedDraft && (
|
||||||
@@ -264,14 +266,19 @@ export function TariffLab() {
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||||||
{result.pricing.graceExpiresAt && (
|
{result.pricing.graceExpiresAt && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{/* HOW the sum is produced — line items from the SAME engine walk
|
||||||
|
(their sum is the amount by construction). */}
|
||||||
|
{result.breakdown && (
|
||||||
|
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||||
@@ -316,7 +323,7 @@ export function TariffLab() {
|
|||||||
>
|
>
|
||||||
<span className="block font-semibold">{d.name}</span>
|
<span className="block font-semibold">{d.name}</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -345,7 +352,7 @@ export function TariffLab() {
|
|||||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
|
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -363,10 +370,10 @@ export function TariffLab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block font-semibold">
|
<span className="block font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
|
|||||||
if (min < 1440) return `${min / 60}h`;
|
if (min < 1440) return `${min / 60}h`;
|
||||||
return `${min / 1440}d`;
|
return `${min / 1440}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fee's line items — every row states its time window / rule and its amount, so
|
||||||
|
* the operator can retrace the exact sum (caps show as negative adjustments). */
|
||||||
|
function BreakdownTable({
|
||||||
|
b,
|
||||||
|
periodStart,
|
||||||
|
currency,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
b: FeeBreakdown;
|
||||||
|
periodStart: string;
|
||||||
|
currency: string;
|
||||||
|
t: TFunction;
|
||||||
|
}) {
|
||||||
|
const startMs = Date.parse(periodStart);
|
||||||
|
const multiDay = b.billedMinutes > 1440;
|
||||||
|
const at = (min: number) => {
|
||||||
|
const iso = new Date(startMs + min * 60_000).toISOString();
|
||||||
|
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
|
||||||
|
};
|
||||||
|
const money = (m: number) => formatMoney(m, currency);
|
||||||
|
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-term-border pt-2">
|
||||||
|
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||||||
|
{b.billedMinutes > 0 && (
|
||||||
|
<p className="hint mb-1.5">
|
||||||
|
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{b.items.map((it, i) => {
|
||||||
|
let label: string;
|
||||||
|
let amount: number;
|
||||||
|
let cls = "text-term-text";
|
||||||
|
switch (it.kind) {
|
||||||
|
case "grace":
|
||||||
|
label = t("lab.bd.grace", { min: it.minutes });
|
||||||
|
amount = 0;
|
||||||
|
cls = "text-term-green";
|
||||||
|
break;
|
||||||
|
case "band":
|
||||||
|
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "package":
|
||||||
|
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "step":
|
||||||
|
label = it.repeated
|
||||||
|
? t("lab.bd.stepRepeated", { day: it.day })
|
||||||
|
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "cap":
|
||||||
|
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
cls = "text-term-red";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-b border-term-border/40">
|
||||||
|
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||||||
|
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr>
|
||||||
|
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||||||
|
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -483,7 +483,11 @@ export interface ReportSeriesPoint {
|
|||||||
entries: number;
|
entries: number;
|
||||||
exits: number;
|
exits: number;
|
||||||
revenueMinor: number;
|
revenueMinor: number;
|
||||||
|
cashMinor: number;
|
||||||
|
cardMinor: number;
|
||||||
payments: number;
|
payments: number;
|
||||||
|
/** Cars inside at the END of the bucket. */
|
||||||
|
occupancyEnd: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportTotals {
|
export interface ReportTotals {
|
||||||
@@ -500,6 +504,8 @@ export interface ReportTotals {
|
|||||||
totalParkedMinutes: number;
|
totalParkedMinutes: number;
|
||||||
avgParkedMinutes: number;
|
avgParkedMinutes: number;
|
||||||
medianParkedMinutes: number;
|
medianParkedMinutes: number;
|
||||||
|
voids: number;
|
||||||
|
anomalies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportSubscriptionStats {
|
export interface ReportSubscriptionStats {
|
||||||
@@ -519,6 +525,12 @@ export interface ReportSummary {
|
|||||||
totals: ReportTotals;
|
totals: ReportTotals;
|
||||||
series: ReportSeriesPoint[];
|
series: ReportSeriesPoint[];
|
||||||
entriesByHour: number[];
|
entriesByHour: number[];
|
||||||
|
/** 7×24, row 0 = Monday — entries heatmap (weekday-vs-weekend patterns). */
|
||||||
|
entriesByDowHour: number[][];
|
||||||
|
/** Stay-duration histogram; last bucket has uptoMin null (>24h tail). */
|
||||||
|
stayHistogram: { uptoMin: number | null; count: number }[];
|
||||||
|
occupancyStart: number;
|
||||||
|
capacity: number | null;
|
||||||
subscriptions: ReportSubscriptionStats;
|
subscriptions: ReportSubscriptionStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +762,9 @@ export interface SimSessionPricing {
|
|||||||
export interface SimulateResult {
|
export interface SimulateResult {
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pricing: SimSessionPricing;
|
pricing: SimSessionPricing;
|
||||||
|
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
|
||||||
|
* null when the session is settled (within walk-back grace). */
|
||||||
|
breakdown: import("@parking/shared").FeeBreakdown | null;
|
||||||
curve: { minutes: number; amountMinor: number }[];
|
curve: { minutes: number; amountMinor: number }[];
|
||||||
gracePeriodExitMin: number;
|
gracePeriodExitMin: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,43 @@ function monthName(d: Date, t: TFn): string {
|
|||||||
return String(d.getMonth() + 1);
|
return String(d.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Short month ("Qer", "Korr") from the catalog — the UI-wide date standard
|
||||||
|
* (2026-07-06): every visible date reads "25 Qer" / "7 Korr 2025", never the
|
||||||
|
* browser-locale "7/6/2026". Falls back to the full name, then the number. */
|
||||||
|
function monthShort(d: Date, t: TFn): string {
|
||||||
|
const months = t("common.monthsShort", { returnObjects: true });
|
||||||
|
if (Array.isArray(months) && typeof months[d.getMonth()] === "string") {
|
||||||
|
return months[d.getMonth()] as string;
|
||||||
|
}
|
||||||
|
return monthName(d, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "HH:mm" (local, 24h) — the unified time-of-day everywhere ("—" for bad input). */
|
||||||
|
export function formatClock(iso: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : hhmm(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "25 Qer" (current year) / "25 Qer 2025" (other years) — the unified DATE. */
|
||||||
|
export function formatDate(iso: string | null, t: TFn): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const base = `${d.getDate()} ${monthShort(d, t)}`;
|
||||||
|
return d.getFullYear() === new Date().getFullYear() ? base : `${base} ${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "25 Qer 14:30" (+ ":ss" when `seconds`) — the unified absolute DATE+TIME. Use
|
||||||
|
* formatRelativeDateTime instead where "Sot/Dje" reads better (feeds, history). */
|
||||||
|
export function formatDateTime(iso: string | null, t: TFn, opts?: { seconds?: boolean }): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const sec = opts?.seconds ? `:${String(d.getSeconds()).padStart(2, "0")}` : "";
|
||||||
|
return `${formatDate(iso, t)} ${hhmm(d)}${sec}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
* Human, day-relative date+time for sessions/logs/history. An event from earlier
|
||||||
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
* today reads "Sot 10:48", yesterday "Dje 17:33", and anything older a localized
|
||||||
@@ -101,9 +138,6 @@ export function formatRelativeDateTime(iso: string | null, t: TFn): string {
|
|||||||
const diff = dayDiff(d, new Date());
|
const diff = dayDiff(d, new Date());
|
||||||
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
if (diff === 0) return `${t("common.today")} ${hhmm(d)}`;
|
||||||
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
if (diff === 1) return `${t("common.yesterday")} ${hhmm(d)}`;
|
||||||
// Older (or future): "17 Qershor 10:48", with the year only if it differs.
|
// Older (or future): "17 Qer 10:48" — the short-month standard, year only if it differs.
|
||||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
return `${formatDate(iso, t)} ${hhmm(d)}`;
|
||||||
const month = monthName(d, t);
|
|
||||||
const date = sameYear ? `${d.getDate()} ${month}` : `${d.getDate()} ${month} ${d.getFullYear()}`;
|
|
||||||
return `${date} ${hhmm(d)}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en: Catalog = {
|
|||||||
"November",
|
"November",
|
||||||
"December",
|
"December",
|
||||||
],
|
],
|
||||||
|
monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Parking System",
|
title: "Parking System",
|
||||||
@@ -330,6 +331,12 @@ export const en: Catalog = {
|
|||||||
hoursUnit: "hours",
|
hoursUnit: "hours",
|
||||||
egHours: "e.g. 2",
|
egHours: "e.g. 2",
|
||||||
pricePerIncrement: "Price / increment (per hour)",
|
pricePerIncrement: "Price / increment (per hour)",
|
||||||
|
pricePerHour: "Price / hour",
|
||||||
|
pricePerN: "Price / {{min}} min",
|
||||||
|
modeFlatN: "Flat price / {{min}} min",
|
||||||
|
perHourEquiv: "= {{amount}} / hour",
|
||||||
|
incrementWarning:
|
||||||
|
"Careful: the billing increment is {{min}} min — every price below is charged per started {{min}} minutes, NOT per hour.",
|
||||||
thereafter: "thereafter (open-ended)",
|
thereafter: "thereafter (open-ended)",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
addBlock: "+ Add block",
|
addBlock: "+ Add block",
|
||||||
@@ -562,6 +569,16 @@ export const en: Catalog = {
|
|||||||
graceExpires: "Grace expires",
|
graceExpires: "Grace expires",
|
||||||
curve: "Duration curve",
|
curve: "Duration curve",
|
||||||
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
|
||||||
|
bd: {
|
||||||
|
title: "How the amount is produced",
|
||||||
|
rounding: "{{raw}} min parked → {{billed}} min billed ({{inc}}-min increments)",
|
||||||
|
grace: "Free — within the entry grace ({{min}} min)",
|
||||||
|
package: "window package",
|
||||||
|
step: "Day {{day}}: stay up to {{hours}}h — total",
|
||||||
|
stepRepeated: "Day {{day}}: beyond the top tier — full-day total",
|
||||||
|
cap: "Daily cap {{cap}} applied (day {{day}})",
|
||||||
|
total: "Total",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Subscriptions",
|
title: "Subscriptions",
|
||||||
@@ -866,14 +883,23 @@ export const en: Catalog = {
|
|||||||
payments: "Payments",
|
payments: "Payments",
|
||||||
avgStay: "Avg stay",
|
avgStay: "Avg stay",
|
||||||
subscribers: "Subscribers",
|
subscribers: "Subscribers",
|
||||||
|
peakOcc: "Peak occupancy",
|
||||||
|
voids: "Voided tickets",
|
||||||
|
anomalies: "Anomalies",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Entries & exits over time",
|
flow: "Entries & exits over time",
|
||||||
|
occupancy: "Occupancy — cars inside",
|
||||||
|
occupancySeries: "Cars inside",
|
||||||
revenue: "Revenue ({{currency}})",
|
revenue: "Revenue ({{currency}})",
|
||||||
mix: "Revenue mix",
|
mix: "Revenue mix",
|
||||||
peakHours: "Entries by hour of day",
|
stay: "Stay duration (closed sessions)",
|
||||||
|
heatmap: "Entries heatmap — hour × day",
|
||||||
breakdown: "Breakdown",
|
breakdown: "Breakdown",
|
||||||
},
|
},
|
||||||
|
capacityLine: "capacity",
|
||||||
|
stay: { m: "m", h: "h" },
|
||||||
|
dowShort: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||||
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
mix: { ticket: "Transient", subSales: "Subscriptions", subWindow: "Out-of-window" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Cash",
|
cash: "Cash",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const sq = {
|
|||||||
"Nëntor",
|
"Nëntor",
|
||||||
"Dhjetor",
|
"Dhjetor",
|
||||||
],
|
],
|
||||||
|
// Short month names — the UI-wide date standard ("25 Qer", "7 Korr").
|
||||||
|
monthsShort: ["Jan", "Shk", "Mar", "Pri", "Maj", "Qer", "Korr", "Gush", "Sht", "Tet", "Nën", "Dhj"],
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
title: "Sistemi i Parkimit",
|
title: "Sistemi i Parkimit",
|
||||||
@@ -332,7 +334,13 @@ export const sq = {
|
|||||||
bandDuration: "Kohëzgjatja e brezit",
|
bandDuration: "Kohëzgjatja e brezit",
|
||||||
hoursUnit: "orë",
|
hoursUnit: "orë",
|
||||||
egHours: "p.sh. 2",
|
egHours: "p.sh. 2",
|
||||||
pricePerIncrement: "Çmimi / interval (orë)",
|
pricePerIncrement: "Çmimi / interval (min)",
|
||||||
|
pricePerHour: "Çmimi / orë",
|
||||||
|
pricePerN: "Çmimi / {{min}} min",
|
||||||
|
modeFlatN: "Çmim fiks / {{min}} min",
|
||||||
|
perHourEquiv: "= {{amount}} / orë",
|
||||||
|
incrementWarning:
|
||||||
|
"Kujdes: intervali i faturimit është {{min}} min — çdo çmim më poshtë faturohet për çdo {{min}} minuta të filluara, JO për orë.",
|
||||||
thereafter: "më pas (i hapur)",
|
thereafter: "më pas (i hapur)",
|
||||||
remove: "Hiq",
|
remove: "Hiq",
|
||||||
addBlock: "+ Shto bllok",
|
addBlock: "+ Shto bllok",
|
||||||
@@ -574,6 +582,16 @@ export const sq = {
|
|||||||
graceExpires: "Afati skadon",
|
graceExpires: "Afati skadon",
|
||||||
curve: "Kurba sipas kohëzgjatjes",
|
curve: "Kurba sipas kohëzgjatjes",
|
||||||
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
|
||||||
|
bd: {
|
||||||
|
title: "Si prodhohet shuma",
|
||||||
|
rounding: "{{raw}} min qëndrim → {{billed}} min të faturuara (njësi {{inc}} min)",
|
||||||
|
grace: "Falas — brenda minutave të hirit ({{min}} min)",
|
||||||
|
package: "paketë dritareje",
|
||||||
|
step: "Dita {{day}}: qëndrim deri në {{hours}}h — total",
|
||||||
|
stepRepeated: "Dita {{day}}: mbi shkallën më të lartë — totali ditor",
|
||||||
|
cap: "U zbatua kufiri ditor {{cap}} (dita {{day}})",
|
||||||
|
total: "Totali",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subs: {
|
subs: {
|
||||||
title: "Abonimet",
|
title: "Abonimet",
|
||||||
@@ -881,14 +899,23 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
avgStay: "Qëndrim mes.",
|
avgStay: "Qëndrim mes.",
|
||||||
subscribers: "Abonentë",
|
subscribers: "Abonentë",
|
||||||
|
peakOcc: "Zënia maksimale",
|
||||||
|
voids: "Bileta të anuluara",
|
||||||
|
anomalies: "Anomali",
|
||||||
},
|
},
|
||||||
chart: {
|
chart: {
|
||||||
flow: "Hyrjet & daljet me kalimin e kohës",
|
flow: "Hyrjet & daljet me kalimin e kohës",
|
||||||
|
occupancy: "Zënia — makina brenda",
|
||||||
|
occupancySeries: "Makina brenda",
|
||||||
revenue: "Të ardhurat ({{currency}})",
|
revenue: "Të ardhurat ({{currency}})",
|
||||||
mix: "Përbërja e të ardhurave",
|
mix: "Përbërja e të ardhurave",
|
||||||
peakHours: "Hyrjet sipas orës së ditës",
|
stay: "Kohëzgjatja e qëndrimit (sesione të mbyllura)",
|
||||||
|
heatmap: "Harta e hyrjeve — orë × ditë",
|
||||||
breakdown: "Ndarja",
|
breakdown: "Ndarja",
|
||||||
},
|
},
|
||||||
|
capacityLine: "kapaciteti",
|
||||||
|
stay: { m: "m", h: "o" },
|
||||||
|
dowShort: ["Hën", "Mar", "Mër", "Enj", "Pre", "Sht", "Die"],
|
||||||
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
mix: { ticket: "Tranzit", subSales: "Abonime", subWindow: "Jashtë orarit" },
|
||||||
row: {
|
row: {
|
||||||
cash: "Para në dorë",
|
cash: "Para në dorë",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
import { fetchDeviceStatus, type DeviceStatus } from "../api.js";
|
||||||
|
import { formatClock } from "../lib/format.js";
|
||||||
import { qk } from "../lib/query.js";
|
import { qk } from "../lib/query.js";
|
||||||
import { useLiveStore } from "../lib/live-store.js";
|
import { useLiveStore } from "../lib/live-store.js";
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ export function DeviceFooter() {
|
|||||||
</div>
|
</div>
|
||||||
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
|
||||||
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
|
||||||
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
|
{t("devices.checkedAt", { time: formatClock(d.checkedAt) })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDateTime } from "../lib/format.js";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
import { fetchSnapshots, snapshotImageUrl, type PlateRead } from "../api.js";
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
key={`${p.plate}-${p.direction}-${i}`}
|
key={`${p.plate}-${p.direction}-${i}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
|
||||||
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
|
||||||
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
|
p.at ? ` · ${formatDateTime(p.at, t)}` : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
|
||||||
@@ -72,7 +73,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setZoom(s.id)}
|
onClick={() => setZoom(s.id)}
|
||||||
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
className="group flex flex-col items-center gap-1 rounded-term border border-term-border bg-term-panel-2 p-1 hover:border-term-amber"
|
||||||
title={`${dirLabel(s.direction)} · ${new Date(s.capturedAt).toLocaleString()}`}
|
title={`${dirLabel(s.direction)} · ${formatDateTime(s.capturedAt, t)}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={snapshotImageUrl(s.id)}
|
src={snapshotImageUrl(s.id)}
|
||||||
@@ -97,7 +98,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
|
|||||||
<div
|
<div
|
||||||
key={`fail-${f.direction ?? "both"}-${i}`}
|
key={`fail-${f.direction ?? "both"}-${i}`}
|
||||||
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
className="flex h-[6.75rem] w-28 flex-col items-center justify-center gap-1 rounded-term border border-dashed border-term-amber/60 bg-term-amber/5 p-1 text-center"
|
||||||
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
|
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${formatDateTime(f.occurredAt, t)}` : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-lg leading-none text-term-amber">⚠</span>
|
<span className="text-lg leading-none text-term-amber">⚠</span>
|
||||||
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { type LedgerEvent } from "../api.js";
|
import { type LedgerEvent } from "../api.js";
|
||||||
import { formatMoney } from "../lib/format.js";
|
import { formatMoney, formatDateTime } from "../lib/format.js";
|
||||||
import { renderReason } from "../lib/reason.js";
|
import { renderReason } from "../lib/reason.js";
|
||||||
import { Modal } from "./Modal.js";
|
import { Modal } from "./Modal.js";
|
||||||
import { SnapshotStrip } from "./SnapshotStrip.js";
|
import { SnapshotStrip } from "./SnapshotStrip.js";
|
||||||
@@ -226,7 +226,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
|
|
||||||
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
{/* Humanized fields — labelled rows, not raw JSON. Only what applies renders. */}
|
||||||
<div>
|
<div>
|
||||||
<DetailRow label={t("booth.edTime")}>{new Date(e.occurredAt).toLocaleString()}</DetailRow>
|
<DetailRow label={t("booth.edTime")}>{formatDateTime(e.occurredAt, t, { seconds: true })}</DetailRow>
|
||||||
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
<DetailRow label={t("booth.edIndex")}>#{e.index}</DetailRow>
|
||||||
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
{e.direction && <DetailRow label={t("booth.edDirection")}>{e.direction}</DetailRow>}
|
||||||
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
{e.source && <DetailRow label={t("booth.edSource")}>{e.source}</DetailRow>}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
|||||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||||
# exists as the pointer; we deploy the sha, not the mover.
|
# exists as the pointer; we deploy the sha, not the mover.
|
||||||
TAG=stage-d905dd1
|
TAG=stage-14638c2
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
VISION_ENABLED=1
|
VISION_ENABLED=1
|
||||||
WS_ALLOWED_ORIGINS=
|
WS_ALLOWED_ORIGINS=
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Driver rename (2026-07-06): "cashino" → "escpos". The driver was always the GENERIC
|
||||||
|
-- ESC/POS printer driver (reachability-only clones); it carried the first unit's vendor
|
||||||
|
-- name, which read as misleading in the setup UI once other clones (ICS/Xprinter
|
||||||
|
-- XP-K200L) used it. Rewrite stored device rows; the registry also keeps a permanent
|
||||||
|
-- cashino→escpos alias so restored pre-rename backups still resolve.
|
||||||
|
UPDATE `devices` SET `driver_id` = 'escpos' WHERE `driver_id` = 'cashino';
|
||||||
@@ -162,6 +162,13 @@
|
|||||||
"when": 1781886500000,
|
"when": 1781886500000,
|
||||||
"tag": "0022_tariff_version_name",
|
"tag": "0022_tariff_version_name",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886600000,
|
||||||
|
"tag": "0023_driver_id_escpos",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
|||||||
export * from "./schema.js";
|
export * from "./schema.js";
|
||||||
// Re-export the query helpers consumers need, so they don't depend on
|
// Re-export the query helpers consumers need, so they don't depend on
|
||||||
// drizzle-orm directly (it's an implementation detail of this package).
|
// drizzle-orm directly (it's an implementation detail of this package).
|
||||||
export { eq, ne, and, or, asc, desc, gte, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
export { eq, ne, and, or, asc, desc, gt, gte, lt, lte, isNull, isNotNull, inArray, sql } from "drizzle-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { registry } from "../registry.js";
|
|||||||
import { dingtianDriver } from "./access-dingtian.js";
|
import { dingtianDriver } from "./access-dingtian.js";
|
||||||
import { stubAccessDriver } from "./access-stub.js";
|
import { stubAccessDriver } from "./access-stub.js";
|
||||||
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
import { dahuaDriver, hikvisionDriver } from "./camera.js";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { rongtaDriver } from "./printer-rongta.js";
|
import { rongtaDriver } from "./printer-rongta.js";
|
||||||
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
import { dingtianQrReaderDriver, tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function registerBuiltinDrivers(): void {
|
|||||||
registry.register(hikvisionDriver);
|
registry.register(hikvisionDriver);
|
||||||
registry.register(dahuaDriver);
|
registry.register(dahuaDriver);
|
||||||
registry.register(rongtaDriver);
|
registry.register(rongtaDriver);
|
||||||
registry.register(cashinoDriver);
|
registry.register(escposDriver);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -35,5 +35,5 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
probeUsb,
|
probeUsb,
|
||||||
sendRawUsb,
|
sendRawUsb,
|
||||||
transportFromConfig,
|
transportFromConfig,
|
||||||
|
writeAllUsb,
|
||||||
stamp,
|
stamp,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
@@ -183,3 +184,63 @@ describe("stamp (Albanian date format)", () => {
|
|||||||
expect(stamp("not-a-date")).toBe("not-a-date");
|
expect(stamp("not-a-date")).toBe("not-a-date");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("writeAllUsb — the char device's partial writes / EAGAIN (field bug 2026-07-06)", () => {
|
||||||
|
// A NONBLOCK usblp fd accepts only what fits the printer's USB buffer per write.
|
||||||
|
// The old single-write path dropped everything past the first buffer — the ICS
|
||||||
|
// XP-K200L printed the ticket's text head but lost the barcode and the cut. A
|
||||||
|
// regular file can't reproduce that, so these drive the loop with a fake handle.
|
||||||
|
|
||||||
|
/** Accepts at most `cap` bytes per call; records everything accepted in order. */
|
||||||
|
function slowHandle(cap: number) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
return {
|
||||||
|
chunks,
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
const n = Math.min(cap, length);
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("delivers the WHOLE payload across many short writes (barcode + cut included)", async () => {
|
||||||
|
const payload = renderTicket({ ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" });
|
||||||
|
const h = slowHandle(100); // way smaller than the job → many partial writes
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000);
|
||||||
|
expect(Buffer.concat(h.chunks).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries EAGAIN (buffer full) until the kernel accepts the rest", async () => {
|
||||||
|
const payload = Buffer.from("x".repeat(300));
|
||||||
|
let calls = 0;
|
||||||
|
const accepted: Buffer[] = [];
|
||||||
|
const h = {
|
||||||
|
write(buffer: Buffer, offset: number, length: number) {
|
||||||
|
calls++;
|
||||||
|
if (calls % 2 === 0) {
|
||||||
|
const err = new Error("EAGAIN") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EAGAIN";
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
const n = Math.min(120, length);
|
||||||
|
accepted.push(Buffer.from(buffer.subarray(offset, offset + n)));
|
||||||
|
return Promise.resolve({ bytesWritten: n });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeAllUsb(h, payload, Date.now() + 2000);
|
||||||
|
expect(Buffer.concat(accepted).equals(payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wedged printer (never accepts a byte) fails at the deadline instead of hanging", async () => {
|
||||||
|
const h = { write: () => Promise.resolve({ bytesWritten: 0 }) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 60)).rejects.toThrow(/usb write timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-EAGAIN error surfaces immediately", async () => {
|
||||||
|
const err = new Error("EIO") as NodeJS.ErrnoException;
|
||||||
|
err.code = "EIO";
|
||||||
|
const h = { write: () => Promise.reject(err) };
|
||||||
|
await expect(writeAllUsb(h, Buffer.from("job"), Date.now() + 1000)).rejects.toThrow("EIO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -600,10 +600,60 @@ function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** usblp accepts only what fits its kernel buffer (~8 KB) per write on a NONBLOCK fd,
|
||||||
|
* so jobs are pushed in chunks safely under that. */
|
||||||
|
const USB_WRITE_CHUNK = 4096;
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** The slice of FileHandle the USB write loop needs (injectable for tests — a real
|
||||||
|
* regular file can't reproduce the char device's partial writes / EAGAIN). */
|
||||||
|
export interface UsbWriteHandle {
|
||||||
|
write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the WHOLE payload through a non-blocking usblp fd. On O_NONBLOCK the kernel
|
||||||
|
* takes only what fits the printer's USB buffer and returns a SHORT write (or EAGAIN
|
||||||
|
* when full) — a single fire-and-forget write() silently drops the tail of any job
|
||||||
|
* bigger than one buffer. That was a real field bug (2026-07-06, ICS XP-K200L over
|
||||||
|
* USB): the text head printed, but the barcode mid-payload and the CUT at the end
|
||||||
|
* were in the dropped tail — "prints, but no barcode and no cut", while the same
|
||||||
|
* bytes over TCP were fine. So: loop until every byte is accepted, retrying EAGAIN
|
||||||
|
* and zero-byte writes with a short pause, bounded by the caller's deadline.
|
||||||
|
*/
|
||||||
|
export async function writeAllUsb(
|
||||||
|
handle: UsbWriteHandle,
|
||||||
|
payload: Buffer,
|
||||||
|
deadlineMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
let off = 0;
|
||||||
|
while (off < payload.length) {
|
||||||
|
if (Date.now() > deadlineMs) {
|
||||||
|
throw new Error(`usb write timeout (${off}/${payload.length} bytes accepted)`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { bytesWritten } = await handle.write(
|
||||||
|
payload,
|
||||||
|
off,
|
||||||
|
Math.min(USB_WRITE_CHUNK, payload.length - off),
|
||||||
|
);
|
||||||
|
off += bytesWritten;
|
||||||
|
if (bytesWritten === 0) await delay(5); // buffer full, no error — breathe
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === "EAGAIN") {
|
||||||
|
await delay(10); // printer draining its buffer — retry until the deadline
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
/** Write an ESC/POS payload to a USB-lp character device (e.g. /dev/usb/lp0). usblp
|
||||||
* is a RAW character device: a single open + write delivers the job — there is no
|
* is a RAW character device — no FIN/half-close dance (that was a TCP concern) —
|
||||||
* FIN/half-close dance (that was a TCP concern, where an early destroy() could
|
* but delivery must go through the chunked loop above (see its doc for why). We
|
||||||
* truncate the stream). We always close the handle (even on a failed write). */
|
* always close the handle (even on a failed write). */
|
||||||
export async function sendRawUsb(
|
export async function sendRawUsb(
|
||||||
devicePath: string,
|
devicePath: string,
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
@@ -615,7 +665,7 @@ export async function sendRawUsb(
|
|||||||
"usb open timeout",
|
"usb open timeout",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await withTimeout(handle.write(payload), timeoutMs, "usb write timeout");
|
await writeAllUsb(handle, payload, Date.now() + timeoutMs);
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
@@ -701,7 +751,7 @@ export const transportField: ConfigField = {
|
|||||||
required: true,
|
required: true,
|
||||||
default: "tcp-ip",
|
default: "tcp-ip",
|
||||||
options: [
|
options: [
|
||||||
{ value: "tcp-ip", label: "Network (raw TCP, port 9100)" },
|
{ value: "tcp-ip", label: "Network (raw TCP)" },
|
||||||
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
{ value: "usb", label: "USB (local /dev/usb/lp0)" },
|
||||||
],
|
],
|
||||||
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
help: "USB drives a printer plugged into the appliance (usblp); Network drives one on the isolated device VLAN.",
|
||||||
|
|||||||
+10
-9
@@ -2,19 +2,20 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { cashinoDriver } from "./printer-cashino.js";
|
import { escposDriver } from "./printer-generic.js";
|
||||||
import { renderTicket } from "./printer-escpos.js";
|
import { renderTicket } from "./printer-escpos.js";
|
||||||
|
|
||||||
// End-to-end transport routing through the real driver: a USB-configured Cashino must
|
// End-to-end transport routing through the real driver: a USB-configured generic
|
||||||
|
// ESC/POS printer (Cashino / ICS XP-K200L family) must
|
||||||
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
// resolve to the char-device transport and write the SAME ESC/POS bytes the TCP path
|
||||||
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
// would. (The TCP path is exercised by the routing/escpos suites and on hardware.)
|
||||||
|
|
||||||
describe("cashinoDriver — USB transport", () => {
|
describe("escposDriver (generic ESC/POS) — USB transport", () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
let devicePath: string;
|
let devicePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(join(tmpdir(), "cashino-usb-"));
|
dir = mkdtempSync(join(tmpdir(), "escpos-usb-"));
|
||||||
devicePath = join(dir, "lp0");
|
devicePath = join(dir, "lp0");
|
||||||
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
// Stand in for an enumerated usblp node (the kernel creates it; we only open it).
|
||||||
writeFileSync(devicePath, "");
|
writeFileSync(devicePath, "");
|
||||||
@@ -24,7 +25,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prints a ticket to the configured USB device path", async () => {
|
it("prints a ticket to the configured USB device path", async () => {
|
||||||
const printer = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const printer = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
const data = { ticketId: "12345678901", issuedAt: "2026-06-21T10:00:00.000Z" };
|
||||||
await printer.printTicket(data);
|
await printer.printTicket(data);
|
||||||
const written = readFileSync(devicePath);
|
const written = readFileSync(devicePath);
|
||||||
@@ -32,10 +33,10 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
it("healthCheck reports ready when the node exists, offline when it doesn't", async () => {
|
||||||
const present = cashinoDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
const present = escposDriver.create({ transport: "usb", devicePath, timeoutMs: 1000 });
|
||||||
expect((await present.healthCheck()).status).toBe("ready");
|
expect((await present.healthCheck()).status).toBe("ready");
|
||||||
// An absent device node (printer unplugged / not enumerated) → offline.
|
// An absent device node (printer unplugged / not enumerated) → offline.
|
||||||
const absent = cashinoDriver.create({
|
const absent = escposDriver.create({
|
||||||
transport: "usb",
|
transport: "usb",
|
||||||
devicePath: join(dir, "absent-lp0"),
|
devicePath: join(dir, "absent-lp0"),
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -44,7 +45,7 @@ describe("cashinoDriver — USB transport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("advertises both transports", () => {
|
it("advertises both transports", () => {
|
||||||
expect(cashinoDriver.transports).toContain("usb");
|
expect(escposDriver.transports).toContain("usb");
|
||||||
expect(cashinoDriver.transports).toContain("tcp-ip");
|
expect(escposDriver.transports).toContain("tcp-ip");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-16
@@ -23,25 +23,26 @@ import {
|
|||||||
type Transport,
|
type Transport,
|
||||||
} from "./printer-escpos.js";
|
} from "./printer-escpos.js";
|
||||||
|
|
||||||
// Cashino 80mm thermal printer driver (network OR USB). The Cashino is an ESC/POS
|
// GENERIC ESC/POS 80mm thermal printer driver (network OR USB) — any clone that
|
||||||
// clone: it PRINTS identically to the Rongta (same byte stream — see
|
// PRINTS the shared ESC/POS byte stream (see ./printer-escpos.ts) but serves no
|
||||||
// ./printer-escpos.ts), so tickets, reports and subscription cards render the same,
|
// Rongta-style decoded status page (/prn_stat.htm). Verified fits: Cashino (the
|
||||||
// over either transport. What it does NOT have is the Rongta board's decoded status
|
// first unit we drove — the driver carried its name until 2026-07-06), ICS/Xprinter
|
||||||
// web page (/prn_stat.htm). It cannot report paper-out / cover-open / cutter faults
|
// XP-K200L. Tickets, reports and subscription cards render identically to the
|
||||||
// in a form we trust.
|
// Rongta, over either transport; what these clones can NOT do is report paper-out /
|
||||||
|
// cover-open / cutter faults in a form we trust.
|
||||||
//
|
//
|
||||||
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
// TRANSPORT: a single `config.transport` ("tcp-ip" | "usb") picks the wire; the
|
||||||
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
// driver resolves it ONCE into a Transport and every print/probe stays transport-
|
||||||
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
// blind (see transportFromConfig/sendTo/probeTo). USB writes the same bytes to a
|
||||||
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
// local usblp char device (/dev/usb/lp0); TCP writes to the raw print socket. This
|
||||||
// clone is the natural USB candidate — reachability-only, no status page to lose.
|
// clone family is the natural USB candidate — reachability-only, no page to lose.
|
||||||
//
|
//
|
||||||
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
// Therefore this driver deliberately does NOT implement MonitorableDevice
|
||||||
// (no readStatus). The device monitor then falls back to the generic
|
// (no readStatus). The device monitor then falls back to the generic
|
||||||
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
// `healthCheck()` — a plain TCP reachability PING of the print socket. So the
|
||||||
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
// booth footer shows this printer as "ready" when it's reachable and "offline"
|
||||||
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
// when it isn't, and never a wrong paper/cover verdict it cannot actually sense.
|
||||||
// (Reusing the Rongta driver made it scrape a status page the Cashino doesn't
|
// (Reusing the Rongta driver made it scrape a status page these clones don't
|
||||||
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
// serve, producing the bogus "degraded" feedback this driver fixes.)
|
||||||
//
|
//
|
||||||
// No auth on the print socket — like the other field devices it lives on the
|
// No auth on the print socket — like the other field devices it lives on the
|
||||||
@@ -49,8 +50,8 @@ import {
|
|||||||
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
// (entry-dispenser / booth-receipt + failoverRank); the server owns selection.
|
||||||
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
// See wiki/concepts/printer-status-monitoring.md and printer-roles-failover.md.
|
||||||
|
|
||||||
class CashinoPrinter implements PrinterDevice {
|
class GenericEscposPrinter implements PrinterDevice {
|
||||||
readonly driverId = "cashino";
|
readonly driverId = "escpos";
|
||||||
readonly #transport: Transport;
|
readonly #transport: Transport;
|
||||||
readonly #timeout: number;
|
readonly #timeout: number;
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class CashinoPrinter implements PrinterDevice {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
* Reachability only — a connect probe (TCP) or char-device open probe (USB) of
|
||||||
* the print path. The Cashino has no trustworthy status protocol, so this is the
|
* the print path. These clones have no trustworthy status protocol, so this is the
|
||||||
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
* floor and the ceiling of what we report: reachable → ready, unreachable →
|
||||||
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
* offline. Deliberately NO readStatus(): the monitor uses this for the
|
||||||
* traffic-light, never a guessed paper/cover state.
|
* traffic-light, never a guessed paper/cover state.
|
||||||
@@ -140,12 +141,16 @@ const rankField: ConfigField = {
|
|||||||
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cashinoDriver: PrinterDriver = {
|
export const escposDriver: PrinterDriver = {
|
||||||
id: "cashino",
|
// Renamed from id "cashino" (the first clone we drove) on 2026-07-06 — the vendor
|
||||||
|
// name was misleading in the setup UI once other clones (ICS/Xprinter XP-K200L)
|
||||||
|
// used it. Stored configs with driverId "cashino" still resolve via the registry
|
||||||
|
// alias + are rewritten by migration 0023.
|
||||||
|
id: "escpos",
|
||||||
category: "printer",
|
category: "printer",
|
||||||
label: "Cashino 80mm thermal printer",
|
label: "Generic ESC/POS 80mm printer (Cashino, ICS/Xprinter…)",
|
||||||
description:
|
description:
|
||||||
"Cashino 80mm thermal printer (ESC/POS over raw TCP port 9100, OR local USB /dev/usb/lp0). Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
"Generic ESC/POS 80mm thermal printer over raw TCP (port 9100) OR local USB /dev/usb/lp0 — Cashino, ICS/Xprinter XP-K200L, and similar clones. Prints like the Rongta but has no status page — monitored by reachability only (no paper/cover/cutter reporting). No auth on the print socket — isolate the VLAN.",
|
||||||
transports: ["tcp-ip", "usb"],
|
transports: ["tcp-ip", "usb"],
|
||||||
configFields: [
|
configFields: [
|
||||||
transportField,
|
transportField,
|
||||||
@@ -167,5 +172,5 @@ export const cashinoDriver: PrinterDriver = {
|
|||||||
default: 3000,
|
default: 3000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
create: (c) => new CashinoPrinter(c),
|
create: (c) => new GenericEscposPrinter(c),
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,7 @@ export {
|
|||||||
hikvisionDriver,
|
hikvisionDriver,
|
||||||
dahuaDriver,
|
dahuaDriver,
|
||||||
rongtaDriver,
|
rongtaDriver,
|
||||||
cashinoDriver,
|
escposDriver,
|
||||||
} from "./drivers/index.js";
|
} from "./drivers/index.js";
|
||||||
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
|
||||||
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
// Albanian human date/time for printed slips (receipts, tickets, shift Z-report),
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export function isDiscoverable(
|
|||||||
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
return typeof (driver as Partial<DiscoverableDriver>).discover === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renamed driver ids: what a STORED config may still say → the current id. Kept
|
||||||
|
* tiny + permanent so old DB rows, exports, and backups resolve across renames
|
||||||
|
* (migration 0023 rewrites live rows, but a restored old backup may reintroduce
|
||||||
|
* the historical id). */
|
||||||
|
const DRIVER_ID_ALIASES: Record<string, string> = {
|
||||||
|
cashino: "escpos", // renamed 2026-07-06 — it was always the generic ESC/POS driver
|
||||||
|
};
|
||||||
|
|
||||||
class DeviceRegistry {
|
class DeviceRegistry {
|
||||||
readonly #drivers = new Map<string, DeviceDriver>();
|
readonly #drivers = new Map<string, DeviceDriver>();
|
||||||
|
|
||||||
@@ -114,12 +122,12 @@ class DeviceRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): DeviceDriver | undefined {
|
get(id: string): DeviceDriver | undefined {
|
||||||
return this.#drivers.get(id);
|
return this.#drivers.get(DRIVER_ID_ALIASES[id] ?? id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate config against a driver's declared fields and build the adapter. */
|
/** Validate config against a driver's declared fields and build the adapter. */
|
||||||
create(id: string, config: DeviceConfig): Device {
|
create(id: string, config: DeviceConfig): Device {
|
||||||
const driver = this.#drivers.get(id);
|
const driver = this.get(id);
|
||||||
if (!driver) throw new Error(`unknown driver: ${id}`);
|
if (!driver) throw new Error(`unknown driver: ${id}`);
|
||||||
for (const field of driver.configFields) {
|
for (const field of driver.configFields) {
|
||||||
if (field.required && config[field.key] === undefined) {
|
if (field.required && config[field.key] === undefined) {
|
||||||
|
|||||||
+170
-12
@@ -764,6 +764,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
return Array.isArray(s.steps) && s.steps.length > 0;
|
return Array.isArray(s.steps) && s.steps.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fee breakdown (explainability) -------------------------------------------
|
||||||
|
// One line item per priced "reason": a run of same-priced increments, a window
|
||||||
|
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
|
||||||
|
// Produced by the SAME walk computeFee runs (an optional trace collector inside
|
||||||
|
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
|
||||||
|
// never tell a different story than the bill. Built for the Tariff Lab's "how is
|
||||||
|
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
|
||||||
|
// period's start.
|
||||||
|
|
||||||
|
export type FeeBreakdownItem =
|
||||||
|
/** The whole stay fit inside the free entry-grace window (fee 0). */
|
||||||
|
| { readonly kind: "grace"; readonly minutes: number }
|
||||||
|
/** A contiguous run of increments billed at one unit price by one card.
|
||||||
|
* `card` is the windowed card's name, or null for the base/default rate. */
|
||||||
|
| {
|
||||||
|
readonly kind: "band";
|
||||||
|
readonly card: string | null;
|
||||||
|
readonly fromMin: number;
|
||||||
|
readonly toMin: number;
|
||||||
|
readonly increments: number;
|
||||||
|
readonly unitMinor: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
}
|
||||||
|
/** One window-package occurrence (charged once per contiguous run the card wins). */
|
||||||
|
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
|
||||||
|
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
|
||||||
|
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
|
||||||
|
| {
|
||||||
|
readonly kind: "step";
|
||||||
|
readonly day: number;
|
||||||
|
readonly dayMinutes: number;
|
||||||
|
readonly uptoMin: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly repeated: boolean;
|
||||||
|
}
|
||||||
|
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
|
||||||
|
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
|
||||||
|
|
||||||
|
export interface FeeBreakdown {
|
||||||
|
/** Actual stay length in whole minutes (before increment rounding). */
|
||||||
|
readonly rawMinutes: number;
|
||||||
|
/** Minutes billed after rounding UP to the increment (0 within grace). */
|
||||||
|
readonly billedMinutes: number;
|
||||||
|
readonly incrementMin: number;
|
||||||
|
readonly items: FeeBreakdownItem[];
|
||||||
|
/** Σ item amounts — always equals computeFee for the same arguments. */
|
||||||
|
readonly totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explain a fee: run the exact computeFee walk with a trace collector and return
|
||||||
|
* the line items plus the total. Same arguments as computeFee; the total returned
|
||||||
|
* here IS computeFee's answer (one code path, not a parallel calculation).
|
||||||
|
*/
|
||||||
|
export function explainFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
category?: string,
|
||||||
|
): FeeBreakdown {
|
||||||
|
const items: FeeBreakdownItem[] = [];
|
||||||
|
const totalMinor = isTariffV2(tariff)
|
||||||
|
? computeFeeV2(enteredAt, asOf, tariff, category, items)
|
||||||
|
: computeFeeV1(enteredAt, asOf, tariff, items);
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const inGrace = items.length === 1 && items[0]!.kind === "grace";
|
||||||
|
const billedMinutes =
|
||||||
|
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
|
||||||
|
? 0
|
||||||
|
: Math.ceil(ms / 60_000 / inc) * inc;
|
||||||
|
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band-merging helper for the trace: accumulate consecutive increments that share
|
||||||
|
* a (card, unit price) and flush them as one `band` item. */
|
||||||
|
class BandTracer {
|
||||||
|
#card: string | null = null;
|
||||||
|
#unit = 0;
|
||||||
|
#from = 0;
|
||||||
|
#count = 0;
|
||||||
|
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
|
||||||
|
add(card: string | null, unitMinor: number, atMin: number): void {
|
||||||
|
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
|
||||||
|
this.#count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flush();
|
||||||
|
this.#card = card;
|
||||||
|
this.#unit = unitMinor;
|
||||||
|
this.#from = atMin;
|
||||||
|
this.#count = 1;
|
||||||
|
}
|
||||||
|
flush(): void {
|
||||||
|
if (this.#count === 0) return;
|
||||||
|
this.items.push({
|
||||||
|
kind: "band",
|
||||||
|
card: this.#card,
|
||||||
|
fromMin: this.#from,
|
||||||
|
toMin: this.#from + this.#count * this.inc,
|
||||||
|
increments: this.#count,
|
||||||
|
unitMinor: this.#unit,
|
||||||
|
amountMinor: this.#count * this.#unit,
|
||||||
|
});
|
||||||
|
this.#count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
||||||
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
||||||
@@ -771,7 +880,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
||||||
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
||||||
*/
|
*/
|
||||||
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
|
||||||
if (minutes <= 0 || steps.length === 0) return 0;
|
if (minutes <= 0 || steps.length === 0) return 0;
|
||||||
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||||
const top = sorted[sorted.length - 1]!;
|
const top = sorted[sorted.length - 1]!;
|
||||||
@@ -780,8 +889,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
||||||
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
||||||
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
||||||
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
const found = sorted.find((s) => dayMin <= s.uptoMin);
|
||||||
|
const tier = found ?? top;
|
||||||
total += tier.totalMinor;
|
total += tier.totalMinor;
|
||||||
|
trace?.push({
|
||||||
|
kind: "step",
|
||||||
|
day: dayStart / DAY + 1,
|
||||||
|
dayMinutes: dayMin,
|
||||||
|
uptoMin: tier.uptoMin,
|
||||||
|
amountMinor: tier.totalMinor,
|
||||||
|
repeated: found == null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -791,30 +909,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||||
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||||
* REPLACES the ladder via {@link steppedFee}. */
|
* REPLACES the ladder via {@link steppedFee}. */
|
||||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
function computeFeeV1(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructureV1,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
|
): number {
|
||||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
||||||
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
|
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
segFee += rateAt(tariff.blocks, within);
|
const unit = rateAt(tariff.blocks, within);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(null, unit, segStart + within);
|
||||||
|
}
|
||||||
|
bands?.flush();
|
||||||
|
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
|
||||||
|
trace?.push({
|
||||||
|
kind: "cap",
|
||||||
|
day: segStart / DAY + 1,
|
||||||
|
capMinor: tariff.dailyCapMinor,
|
||||||
|
amountMinor: tariff.dailyCapMinor - segFee,
|
||||||
|
});
|
||||||
|
segFee = tariff.dailyCapMinor;
|
||||||
}
|
}
|
||||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@@ -837,12 +975,16 @@ function computeFeeV2(
|
|||||||
asOf: string,
|
asOf: string,
|
||||||
tariff: TariffStructureV2,
|
tariff: TariffStructureV2,
|
||||||
category?: string,
|
category?: string,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
): number {
|
): number {
|
||||||
const enteredMs = Date.parse(enteredAt);
|
const enteredMs = Date.parse(enteredAt);
|
||||||
const ms = Date.parse(asOf) - enteredMs;
|
const ms = Date.parse(asOf) - enteredMs;
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0; // grace on RAW duration (V1 rule)
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||||
|
|
||||||
@@ -862,7 +1004,11 @@ function computeFeeV2(
|
|||||||
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
||||||
// windowed cards (they have nothing to override at the increment level). This is the
|
// windowed cards (they have nothing to override at the increment level). This is the
|
||||||
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
||||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
|
||||||
|
|
||||||
|
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
|
||||||
|
// its name.
|
||||||
|
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||||
@@ -876,21 +1022,33 @@ function computeFeeV2(
|
|||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
for (let within = segStart; within < segEnd; within += inc) {
|
for (let within = segStart; within < segEnd; within += inc) {
|
||||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||||
const card = selectCard(cards, wall);
|
const card = selectCard(cards, wall);
|
||||||
if (card.packageMinor != null) {
|
if (card.packageMinor != null) {
|
||||||
// First increment of a new occurrence pays the package; the rest ride free.
|
// First increment of a new occurrence pays the package; the rest ride free.
|
||||||
if (prevWinner !== card) segFee += card.packageMinor;
|
if (prevWinner !== card) {
|
||||||
|
segFee += card.packageMinor;
|
||||||
|
bands?.flush();
|
||||||
|
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
|
||||||
|
}
|
||||||
} else if (card.flatMinor != null) {
|
} else if (card.flatMinor != null) {
|
||||||
segFee += card.flatMinor;
|
segFee += card.flatMinor;
|
||||||
|
bands?.add(traceName(card), card.flatMinor, within);
|
||||||
} else {
|
} else {
|
||||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
const unit = rateAt(card.blocks ?? [], within - segStart);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(traceName(card), unit, within);
|
||||||
}
|
}
|
||||||
prevWinner = card;
|
prevWinner = card;
|
||||||
}
|
}
|
||||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
bands?.flush();
|
||||||
|
if (dayCap != null && segFee > dayCap) {
|
||||||
|
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
|
||||||
|
segFee = dayCap;
|
||||||
|
}
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
|
type FeeBreakdownItem,
|
||||||
|
type TariffStructure,
|
||||||
type TariffStructureV1,
|
type TariffStructureV1,
|
||||||
type TariffStructureV2,
|
type TariffStructureV2,
|
||||||
type TariffCard,
|
type TariffCard,
|
||||||
@@ -437,3 +440,95 @@ describe("V2 window package (whole-window total)", () => {
|
|||||||
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
|
||||||
|
const v1: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [
|
||||||
|
{ uptoMin: 120, priceMinorPerIncrement: 200 },
|
||||||
|
{ uptoMin: null, priceMinorPerIncrement: 100 },
|
||||||
|
],
|
||||||
|
dailyCapMinor: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
|
||||||
|
|
||||||
|
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
|
||||||
|
const from = "2026-07-06T08:00:00.000Z";
|
||||||
|
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
|
||||||
|
const b = explainFee(from, to, v1);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v1));
|
||||||
|
expect(b.totalMinor).toBe(500);
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
|
||||||
|
const [first, second, cap] = b.items as [
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "cap" }>,
|
||||||
|
];
|
||||||
|
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
|
||||||
|
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
|
||||||
|
expect(cap.amountMinor).toBe(-500);
|
||||||
|
expect(b.billedMinutes).toBe(480);
|
||||||
|
expect(b.rawMinutes).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace: one zero line, billed 0", () => {
|
||||||
|
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
|
||||||
|
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
|
||||||
|
expect(b.totalMinor).toBe(0);
|
||||||
|
expect(b.billedMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepped: one line per rolling day, top tier repeats flagged", () => {
|
||||||
|
const stepped: TariffStructure = {
|
||||||
|
...v1,
|
||||||
|
blocks: [],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
steps: [
|
||||||
|
{ uptoMin: 180, totalMinor: 500 },
|
||||||
|
{ uptoMin: 1440, totalMinor: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const from = "2026-07-04T08:00:00.000Z";
|
||||||
|
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
|
||||||
|
const b = explainFee(from, to, stepped);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
|
||||||
|
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
|
||||||
|
const v2: TariffStructure = {
|
||||||
|
version: 2,
|
||||||
|
tz: "Europe/Tirane",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [
|
||||||
|
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
|
||||||
|
const from = "2026-07-06T16:00:00.000Z";
|
||||||
|
const to = "2026-07-06T20:30:00.000Z";
|
||||||
|
const b = explainFee(from, to, v2);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v2));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.totalMinor).toBe(2 * 10000 + 40000);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
|
||||||
|
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-24
|
updated: 2026-07-06
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,5 +87,28 @@ The setup UI offers a **Connection** select (Network / USB) + a **USB device** p
|
|||||||
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
to the node and reports ready/offline). The on-hardware confirmation + the udev/usblp provisioning
|
||||||
are pending (open-questions #14).
|
are pending (open-questions #14).
|
||||||
|
|
||||||
|
## Field bug — the NONBLOCK partial-write truncation (found + fixed 2026-07-06)
|
||||||
|
|
||||||
|
First on-hardware USB test (ICS XP-K200L, an ESC/POS clone): over TCP it printed + cut fine; over
|
||||||
|
USB it printed the ticket's TEXT but **no barcode and no cut**. Root cause was in OUR transport,
|
||||||
|
not the printer: `sendRawUsb` opened the node with `O_NONBLOCK` and issued ONE `write()` for the
|
||||||
|
whole job. On a non-blocking usblp fd the kernel accepts only what fits the printer's USB buffer
|
||||||
|
(~8 KB) and returns a **short write**; the old code never checked `bytesWritten`, closed the
|
||||||
|
handle, and silently dropped the tail — which is exactly where the barcode (mid-payload) and the
|
||||||
|
CUT (last bytes) live. Small jobs fit one buffer, hence "text prints fine". The regular-file test
|
||||||
|
stand-in can't short-write, so tests never caught it.
|
||||||
|
|
||||||
|
Fix: `writeAllUsb` — chunked loop (4 KB, safely under the usblp buffer) that continues after
|
||||||
|
partial writes, retries `EAGAIN`/zero-byte writes with a short pause, and fails at the caller's
|
||||||
|
deadline with a `(N/M bytes accepted)` diagnostic. Driven by fake-handle tests (short writes,
|
||||||
|
EAGAIN interleave, wedged-printer timeout, non-EAGAIN passthrough) since a real file can't
|
||||||
|
reproduce the char device's behaviour.
|
||||||
|
|
||||||
|
> Driver-choice note for this clone: the ICS XP-K200L does NOT serve the Rongta `/prn_stat.htm`
|
||||||
|
> status page (checked on hardware at 10.0.10.11 — print socket 9100 open, status page absent),
|
||||||
|
> so on NETWORK the honest driver is **cashino** (reachability-only monitoring); under `rongta`
|
||||||
|
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||||
|
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||||
|
|
||||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, reporting]
|
tags: [parking, domain, business, reporting]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-22
|
updated: 2026-07-05
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,6 +73,30 @@ disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and
|
|||||||
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
||||||
([[open-questions]] #4).
|
([[open-questions]] #4).
|
||||||
|
|
||||||
|
## As-built additions (2026-07-05) — the parking-shaped graphics
|
||||||
|
|
||||||
|
Operator ask: "check /reports for improvements and meaningful graphics." The dashboard (Recharts,
|
||||||
|
ledger-first aggregation in `apps/server/src/reports.ts`) gained the three views that are
|
||||||
|
parking-specific rather than generic BI, plus fraud counters:
|
||||||
|
|
||||||
|
- **Occupancy curve** — cars-inside 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`. The at-a-glance answer to "when are we
|
||||||
|
near full" — the input for capacity and dynamic-window decisions.
|
||||||
|
- **Entries heatmap (hour × day-of-week)** — 7×24 matrix (`entriesByDowHour`, row 0 = Monday,
|
||||||
|
site-tz), rendered as a pure CSS-grid amber-intensity map. Shows weekday-vs-weekend and
|
||||||
|
morning/evening patterns — the direct evidence for tariff windows (night rates, weekend cards,
|
||||||
|
early-bird — see [[tariff-industry-survey]]). Replaces the flat entries-by-hour bar (strictly
|
||||||
|
contains it).
|
||||||
|
- **Stay-duration histogram** — closed sessions bucketed at 30m/1h/2h/4h/8h/24h/tail
|
||||||
|
(`stayHistogram`): where the ladder/up-to breakpoints should sit.
|
||||||
|
- **Look-closer counters** — voids + anomalies in range as KPI cards (accented when >0): a spike
|
||||||
|
is exactly what the signed chain exists to surface (the operator is the threat model).
|
||||||
|
- Revenue bars now **stack cash vs card** per bucket (`cashMinor`/`cardMinor` on each point —
|
||||||
|
the drawer's money vs the bank's); peak-occupancy KPI (`peak / capacity`); CSV export gained
|
||||||
|
cash, card, occupancy_end columns. `localParts` now caches its Intl formatter per tz (was one
|
||||||
|
`new Intl.DateTimeFormat` per ledger row).
|
||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
- Which reports matter at launch vs. later; the export format/cadence.
|
- Which reports matter at launch vs. later; the export format/cadence.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: reference
|
type: reference
|
||||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-30
|
updated: 2026-07-06
|
||||||
status: settled
|
status: settled
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -384,8 +384,41 @@ docker exec -it \
|
|||||||
> setup only). It is a **training/demo** tool — never run on a production booth's data.
|
> setup only). It is a **training/demo** tool — never run on a production booth's data.
|
||||||
|
|
||||||
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
|
After `--users`/`--all` (users cleared), re-seed the first admin exactly as in §7b
|
||||||
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. For dev (where `pnpm` exists)
|
(`docker exec … node scripts/seed-admin.mjs`) so someone can log back in. Since 2026-07-06 the seed
|
||||||
the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
script **self-heals the built-in `admin` role row** that this reset also wipes — before that fix the
|
||||||
|
documented re-seed died on a `role_id` FOREIGN KEY error (field failure on `park-buzi`). For dev
|
||||||
|
(where `pnpm` exists) the same script is `pnpm db:reset --financial` — see [[local-dev-workflow]].
|
||||||
|
|
||||||
|
### 7e. Lost APP admin password — reset from the Linux admin account (2026-07-06)
|
||||||
|
|
||||||
|
The app's admin password lives only as a bcrypt hash in the booth DB; there is no in-app recovery
|
||||||
|
(nobody above the admin exists to send a reset). The recovery path is the **Linux `admin` account**
|
||||||
|
(the only user in the `docker` group): the seed script doubles as the password-reset tool via
|
||||||
|
`FORCE=1` — on an existing username it RESETS that user's password (and restores `roleId: admin`,
|
||||||
|
so it also rescues a demoted admin).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive (preferred — the password never lands in shell history):
|
||||||
|
docker exec -it -e FORCE=1 park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
# → prompts: username (Enter = admin), new password (min 8 chars)
|
||||||
|
|
||||||
|
# Non-interactive (scripted; NB the password enters the HOST's shell history):
|
||||||
|
docker exec -e FORCE=1 -e ADMIN_USER=admin -e ADMIN_PASS='new-strong-pass' \
|
||||||
|
park-buzi-server-1 node scripts/seed-admin.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Attributable, not gated.** Whoever holds Linux root owns the DB file — the app cannot defend
|
||||||
|
against that actor and doesn't pretend to. What it CAN do: the script appends a **signed
|
||||||
|
`config_change` ledger event** (`admin.passwordReset` / `admin.seeded` on first seed, operator
|
||||||
|
`console:seed-admin`) so a console reset stays visible in the chain afterwards. If the signing key
|
||||||
|
is unavailable (e.g. a dev shell), it warns loudly and proceeds — locking an admin out to protect
|
||||||
|
an audit line would invert the priority. The [[threat-model]] adversary remains the *operator*,
|
||||||
|
who has no Linux account at all.
|
||||||
|
- **Sessions are NOT revoked** by a password reset — issued JWT cookies ride to expiry. A *forgotten*
|
||||||
|
password needs nothing more; a *suspected-stolen* one should also rotate the booth's `JWT_SECRET`
|
||||||
|
(Komodo Variables → redeploy), which invalidates every session instantly.
|
||||||
|
- Works on a fresh/reset DB too (the role-row self-heal above), so §7b first-seed, §7d post-reset
|
||||||
|
re-seed, and this recovery are all the same one command.
|
||||||
|
|
||||||
### Healthy startup + web-access
|
### Healthy startup + web-access
|
||||||
|
|
||||||
|
|||||||
+87
@@ -2372,3 +2372,90 @@ The published-history sidebar built for the lab landed only there; the operator
|
|||||||
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
|
effective date, active badge), click → formFromVersion loads it into the editor as the seed for
|
||||||
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
|
the next publish (which, per the sidebar hint, always creates a NEW immutable version). Details on
|
||||||
[[tariff]] (Composer section).
|
[[tariff]] (Composer section).
|
||||||
|
|
||||||
|
## [2026-07-05] update | Reports dashboard: occupancy curve, hour×dow heatmap, stay histogram, fraud KPIs
|
||||||
|
|
||||||
|
Operator ask on /reports. Added the parking-shaped graphics (details on [[reporting-analytics]]):
|
||||||
|
occupancy step-area vs capacity line (prior-ledger fold for range-start, per-bucket occupancyEnd),
|
||||||
|
entries heatmap hour×day-of-week (replaces the flat hour histogram; feeds tariff-window design per
|
||||||
|
[[tariff-industry-survey]]), stay-duration histogram at tariff-shaped edges (30m/1h/2h/4h/8h/24h),
|
||||||
|
voids+anomalies KPI counters (accented when >0 — the look-closer signal), cash/card stacked revenue
|
||||||
|
bars, peak-occupancy KPI, richer CSV (cash/card/occupancy_end). Server: reports.ts aggregation +
|
||||||
|
Intl formatter cached per tz; db package re-exports lt/gt. 5 new tests (295 server green).
|
||||||
|
|
||||||
|
## [2026-07-06] update | USB printer truncation fixed (NONBLOCK partial write) — ICS XP-K200L
|
||||||
|
|
||||||
|
First on-hardware USB print test failed exactly as the transport bug predicts: text printed,
|
||||||
|
barcode + cut missing (both live in the dropped tail). sendRawUsb did ONE write() on an O_NONBLOCK
|
||||||
|
usblp fd and never checked bytesWritten — anything past the printer's ~8 KB USB buffer was
|
||||||
|
silently discarded; TCP was immune. Fixed with writeAllUsb (4 KB chunks, partial-write
|
||||||
|
continuation, EAGAIN retry, deadline with N/M diagnostic) + 4 fake-handle tests. Also verified on
|
||||||
|
hardware (10.0.10.11): the ICS XP-K200L serves NO /prn_stat.htm → on network use the cashino
|
||||||
|
driver (reachability-only), not rongta, or monitoring calls a working printer offline. Details on
|
||||||
|
[[printer-usb-transport]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Driver rename: "cashino" → "escpos" (generic ESC/POS printer)
|
||||||
|
|
||||||
|
The ICS XP-K200L exposed that the reachability-only clone driver carried its first unit's vendor
|
||||||
|
name — "cashino" in the setup UI was misleading for every other clone. Renamed properly:
|
||||||
|
printer-cashino.ts → printer-generic.ts, id "cashino" → "escpos", label "Generic ESC/POS 80mm
|
||||||
|
printer (Cashino, ICS/Xprinter…)". Stored rows rewritten by migration 0023; the registry keeps a
|
||||||
|
PERMANENT cashino→escpos alias so restored pre-rename backups still resolve. Prose mentions of the
|
||||||
|
Cashino as hardware stay (it's a real printer). Driver guidance for the XP-K200L: escpos on both
|
||||||
|
transports (it serves no /prn_stat.htm — verified; rongta would false-flag it). See
|
||||||
|
[[printer-usb-transport]], [[printer-status-monitoring]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Setup wizard: printers no longer forced to bind to a barrier relay
|
||||||
|
|
||||||
|
Operator hit the wizard's blanket "pick the controller and relay this device sits at" gate while
|
||||||
|
adding the ICS printer. The binding (controllerId+relay → which barrier a scan opens + inherited
|
||||||
|
direction) is load-bearing for READERS and CAMERAS only; nothing consumes it on a printer —
|
||||||
|
printer routing is role + failoverRank (printer-routing.ts). Wizard now skips the requirement,
|
||||||
|
hides the "Cilën barrierë shërben kjo pajisje?" panel, and stops persisting the binding for
|
||||||
|
printers (a stale pre-fix binding drops off on next edit); the device list shows the printer's
|
||||||
|
ROLE instead of a bogus amber "unbound". Server never required it (no validation change).
|
||||||
|
|
||||||
|
## [2026-07-06] update | Tariff Lab: fee breakdown — "how is this sum produced"
|
||||||
|
|
||||||
|
Operator: a lab outcome of "ALL 740 / 3h 2m" gave no derivation. Added explainFee to
|
||||||
|
@parking/shared: the SAME computeFee walk with an optional trace collector (zero fee change —
|
||||||
|
golden V1 regression still green), so Σ line items ≡ the amount by construction. Items: banded
|
||||||
|
same-price increment runs (time window · N × unit · card name), window-package occurrences,
|
||||||
|
stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry
|
||||||
|
grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel
|
||||||
|
renders it as a lined table with the rounding note (raw min → billed min at the increment) and a
|
||||||
|
total row. 4 new engine tests pin the sum invariant + item shapes. This also largely delivers the
|
||||||
|
wiki's open "composer price preview" item — see [[tariff]].
|
||||||
|
|
||||||
|
## [2026-07-06] update | Composer: increment-unit price labels + ≠60 warning (the 60→10 trap)
|
||||||
|
|
||||||
|
Operator walked into the sharp edge the wiki flat-rate warning had already named: ladder/flat
|
||||||
|
prices are PER BILLING INCREMENT, so changing "Intervali i faturimit" 60→10 silently multiplies
|
||||||
|
every price ×6, while the price header just said "Çmimi / interval". Composer now: price labels
|
||||||
|
are DYNAMIC ("Çmimi / orë" at 60, "Çmimi / {{N}} min" otherwise — same for the flat-mode radio),
|
||||||
|
and an amber warning appears whenever the increment ≠ 60 ("çdo çmim faturohet për çdo N minuta,
|
||||||
|
JO për orë"). Band DURATIONS stay in hours — they're real wall time, increment-independent (the
|
||||||
|
operator asked if "orë" there was wrong; it isn't). See [[tariff]] (§increment).
|
||||||
|
|
||||||
|
## [2026-07-06] update | UI-wide date standard ("25 Qer") + currency-scaled composer examples
|
||||||
|
|
||||||
|
Two operator UX complaints. (1) Dates were a mix of browser-locale "7/6/2026" (raw
|
||||||
|
toLocaleString) and catalog "25 Qershor" — unified: formatDate/formatDateTime/formatClock in
|
||||||
|
lib/format.ts using new common.monthsShort ("25 Qer 14:30", year only when ≠ current, 24h clock);
|
||||||
|
formatRelativeDateTime switched to short months; ALL ~20 raw toLocale* date call sites swept
|
||||||
|
(shifts, subs, plans, drawer, snapshots, device footer, event detail, tariff composer + lab incl.
|
||||||
|
the fee-breakdown row times). Number toLocaleString (thousand separators on money) untouched.
|
||||||
|
(2) Composer example defaults were euro-scaled ("2.00"/hour ≈ 2 lekë) — now currency-aware
|
||||||
|
(ALL: 200/100 ladder, 200/500 steps, 2000 lost ticket; EUR/USD keep 2/1/2/5/20), threaded through
|
||||||
|
emptyForm/emptyLadder/emptyTier/pricingFromCard so a mode switch on an ALL card also shows lek-
|
||||||
|
plausible templates. Blank-form currency stays ALL.
|
||||||
|
|
||||||
|
## [2026-07-06] update | seed-admin signs a ledger event; lost-app-admin-password runbook (§7e)
|
||||||
|
|
||||||
|
Follow-through on the FK fix: seed-admin.mjs now appends a signed config_change
|
||||||
|
(admin.passwordReset / admin.seeded, operator console:seed-admin) via the server's compiled
|
||||||
|
EventLog + signer from dist/ — a console reset by the Linux admin can't gate on the app, but it
|
||||||
|
stays attributable in the chain. Best-effort: no build/key → loud warning, seed still proceeds
|
||||||
|
(verified both paths on a scratch DB). [[appliance-provisioning]] gained §7e: FORCE=1 reset
|
||||||
|
commands (interactive preferred — keeps the password out of shell history), sessions-not-revoked
|
||||||
|
caveat + JWT_SECRET rotation for suspected theft, role-row self-heal note added to §7d.
|
||||||
|
|||||||
Reference in New Issue
Block a user