From 88a80d2268bdd03eeb63811794a3bb22aa40d0d3 Mon Sep 17 00:00:00 2001 From: Timothy Yin Date: Thu, 12 Mar 2026 17:38:49 +0800 Subject: [PATCH] feat(transactions): add live energy and estimated cost to transaction data --- apps/csms/src/routes/transactions.ts | 95 ++++++++++++++++---- apps/web/app/dashboard/transactions/page.tsx | 26 +++++- apps/web/app/globals.css | 6 ++ apps/web/lib/api.ts | 2 + 4 files changed, 110 insertions(+), 19 deletions(-) diff --git a/apps/csms/src/routes/transactions.ts b/apps/csms/src/routes/transactions.ts index 1dc30ec..396d19a 100644 --- a/apps/csms/src/routes/transactions.ts +++ b/apps/csms/src/routes/transactions.ts @@ -2,7 +2,8 @@ import { Hono } from "hono"; import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; -import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; +import { transaction, chargePoint, connector, idTag, meterValue } from "@/db/schema.js"; +import type { SampledValue } from "@/db/schema.js"; import { user } from "@/db/auth-schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js"; @@ -20,11 +21,13 @@ app.post("/remote-start", async (c) => { if (!currentUser) return c.json({ error: "Unauthorized" }, 401); const db = useDrizzle(); - const body = await c.req.json<{ - chargePointIdentifier: string; - connectorId: number; - idTag: string; - }>().catch(() => null); + const body = await c.req + .json<{ + chargePointIdentifier: string; + connectorId: number; + idTag: string; + }>() + .catch(() => null); if ( !body || @@ -122,6 +125,8 @@ app.get("/", async (c) => { .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, + feePerKwh: chargePoint.feePerKwh, + pricingMode: chargePoint.pricingMode, connectorNumber: connector.connectorId, idTagUserId: idTag.userId, idTagUserName: user.name, @@ -136,18 +141,74 @@ app.get("/", async (c) => { .limit(limit) .offset(offset); + // For active transactions, fetch the latest meter reading to show live energy + const activeTxIds = rows.filter((r) => !r.transaction.stopTimestamp).map((r) => r.transaction.id); + + // Map: transactionId -> latest cumulative Wh from meterValue + const latestMeterMap = new Map(); + if (activeTxIds.length > 0) { + // DISTINCT ON picks the most recent row per transaction_id + const latestRows = await db.execute<{ + transaction_id: number; + sampled_values: SampledValue[]; + }>(sql` + SELECT DISTINCT ON (transaction_id) transaction_id, sampled_values + FROM meter_value + WHERE transaction_id IN (${sql.join( + activeTxIds.map((id) => sql`${id}`), + sql`, `, + )}) + ORDER BY transaction_id, timestamp DESC + `); + for (const row of latestRows.rows) { + const svList = row.sampled_values as SampledValue[]; + const energySv = svList.find( + (sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase, + ); + if (energySv != null) { + // Unit defaults to Wh; kWh is also common + const raw = parseFloat(energySv.value); + if (!Number.isNaN(raw)) { + const wh = energySv.unit === "kWh" ? raw * 1000 : raw; + latestMeterMap.set(Number(row.transaction_id), wh); + } + } + } + } + return c.json({ - data: rows.map((r) => ({ - ...r.transaction, - chargePointIdentifier: r.chargePointIdentifier, - connectorNumber: r.connectorNumber, - idTagUserId: r.idTagUserId, - idTagUserName: r.idTagUserName, - energyWh: - r.transaction.stopMeterValue != null - ? r.transaction.stopMeterValue - r.transaction.startMeterValue - : null, - })), + data: rows.map((r) => { + const isActive = !r.transaction.stopTimestamp; + const latestMeterWh = isActive ? (latestMeterMap.get(r.transaction.id) ?? null) : null; + const liveEnergyWh = + latestMeterWh != null ? latestMeterWh - r.transaction.startMeterValue : null; + + // Estimated cost: only for fixed pricing (TOU requires full interval analysis) + let estimatedCost: number | null = null; + if ( + isActive && + liveEnergyWh != null && + liveEnergyWh > 0 && + r.pricingMode === "fixed" && + (r.feePerKwh ?? 0) > 0 + ) { + estimatedCost = Math.ceil((liveEnergyWh * (r.feePerKwh ?? 0)) / 1000); + } + + return { + ...r.transaction, + chargePointIdentifier: r.chargePointIdentifier, + connectorNumber: r.connectorNumber, + idTagUserId: r.idTagUserId, + idTagUserName: r.idTagUserName, + energyWh: + r.transaction.stopMeterValue != null + ? r.transaction.stopMeterValue - r.transaction.startMeterValue + : null, + liveEnergyWh, + estimatedCost, + }; + }), total, page, totalPages: Math.max(1, Math.ceil(total / limit)), diff --git a/apps/web/app/dashboard/transactions/page.tsx b/apps/web/app/dashboard/transactions/page.tsx index 5c3fe70..3d5dff2 100644 --- a/apps/web/app/dashboard/transactions/page.tsx +++ b/apps/web/app/dashboard/transactions/page.tsx @@ -153,10 +153,32 @@ export default function TransactionsPage() { {formatDuration(tx.startTimestamp, tx.stopTimestamp)} - {tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"} + {tx.energyWh != null ? ( + (tx.energyWh / 1000).toFixed(3) + ) : tx.liveEnergyWh != null ? ( + + {(tx.liveEnergyWh / 1000).toFixed(3)} + + 预估 + + + ) : ( + "—" + )} - {tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"} + {tx.chargeAmount != null ? ( + `¥${(tx.chargeAmount / 100).toFixed(2)}` + ) : tx.estimatedCost != null ? ( + + ¥{(tx.estimatedCost / 100).toFixed(2)} + + 预估 + + + ) : ( + "—" + )} {tx.stopReason ? ( diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bf8073f..56c13c4 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -92,3 +92,9 @@ --warning: oklch(82.03% 0.1407 76.34); --warning-foreground: oklch(21.03% 0.0059 76.34); } + +@layer components { + .chip__label { + @apply text-nowrap; + } +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 571c7b8..babdd4d 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -120,6 +120,8 @@ export type Transaction = { startMeterValue: number | null; stopMeterValue: number | null; energyWh: number | null; + liveEnergyWh: number | null; + estimatedCost: number | null; stopIdTag: string | null; stopReason: string | null; chargeAmount: number | null;