feat(transactions): add live energy and estimated cost to transaction data

This commit is contained in:
2026-03-12 17:38:49 +08:00
parent f7ee298060
commit 88a80d2268
4 changed files with 110 additions and 19 deletions

View File

@@ -2,7 +2,8 @@ import { Hono } from "hono";
import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js"; 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 { user } from "@/db/auth-schema.js";
import { ocppConnections } from "@/ocpp/handler.js"; import { ocppConnections } from "@/ocpp/handler.js";
import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.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); if (!currentUser) return c.json({ error: "Unauthorized" }, 401);
const db = useDrizzle(); const db = useDrizzle();
const body = await c.req.json<{ const body = await c.req
.json<{
chargePointIdentifier: string; chargePointIdentifier: string;
connectorId: number; connectorId: number;
idTag: string; idTag: string;
}>().catch(() => null); }>()
.catch(() => null);
if ( if (
!body || !body ||
@@ -122,6 +125,8 @@ app.get("/", async (c) => {
.select({ .select({
transaction, transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointIdentifier: chargePoint.chargePointIdentifier,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
connectorNumber: connector.connectorId, connectorNumber: connector.connectorId,
idTagUserId: idTag.userId, idTagUserId: idTag.userId,
idTagUserName: user.name, idTagUserName: user.name,
@@ -136,8 +141,61 @@ app.get("/", async (c) => {
.limit(limit) .limit(limit)
.offset(offset); .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<number, number>();
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({ return c.json({
data: rows.map((r) => ({ 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, ...r.transaction,
chargePointIdentifier: r.chargePointIdentifier, chargePointIdentifier: r.chargePointIdentifier,
connectorNumber: r.connectorNumber, connectorNumber: r.connectorNumber,
@@ -147,7 +205,10 @@ app.get("/", async (c) => {
r.transaction.stopMeterValue != null r.transaction.stopMeterValue != null
? r.transaction.stopMeterValue - r.transaction.startMeterValue ? r.transaction.stopMeterValue - r.transaction.startMeterValue
: null, : null,
})), liveEnergyWh,
estimatedCost,
};
}),
total, total,
page, page,
totalPages: Math.max(1, Math.ceil(total / limit)), totalPages: Math.max(1, Math.ceil(total / limit)),

View File

@@ -153,10 +153,32 @@ export default function TransactionsPage() {
{formatDuration(tx.startTimestamp, tx.stopTimestamp)} {formatDuration(tx.startTimestamp, tx.stopTimestamp)}
</Table.Cell> </Table.Cell>
<Table.Cell className="tabular-nums"> <Table.Cell className="tabular-nums">
{tx.energyWh != null ? (tx.energyWh / 1000).toFixed(3) : "—"} {tx.energyWh != null ? (
(tx.energyWh / 1000).toFixed(3)
) : tx.liveEnergyWh != null ? (
<span className="inline-flex items-center gap-1">
{(tx.liveEnergyWh / 1000).toFixed(3)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell> </Table.Cell>
<Table.Cell className="tabular-nums"> <Table.Cell className="tabular-nums">
{tx.chargeAmount != null ? `¥${(tx.chargeAmount / 100).toFixed(2)}` : "—"} {tx.chargeAmount != null ? (
`¥${(tx.chargeAmount / 100).toFixed(2)}`
) : tx.estimatedCost != null ? (
<span className="inline-flex items-center gap-1">
¥{(tx.estimatedCost / 100).toFixed(2)}
<span className="rounded bg-warning-soft px-1 py-0.5 text-[10px] font-semibold text-warning leading-none text-nowrap">
</span>
</span>
) : (
"—"
)}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
{tx.stopReason ? ( {tx.stopReason ? (

View File

@@ -92,3 +92,9 @@
--warning: oklch(82.03% 0.1407 76.34); --warning: oklch(82.03% 0.1407 76.34);
--warning-foreground: oklch(21.03% 0.0059 76.34); --warning-foreground: oklch(21.03% 0.0059 76.34);
} }
@layer components {
.chip__label {
@apply text-nowrap;
}
}

View File

@@ -120,6 +120,8 @@ export type Transaction = {
startMeterValue: number | null; startMeterValue: number | null;
stopMeterValue: number | null; stopMeterValue: number | null;
energyWh: number | null; energyWh: number | null;
liveEnergyWh: number | null;
estimatedCost: number | null;
stopIdTag: string | null; stopIdTag: string | null;
stopReason: string | null; stopReason: string | null;
chargeAmount: number | null; chargeAmount: number | null;