feat(transactions): add live energy and estimated cost to transaction data
This commit is contained in:
@@ -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
|
||||||
chargePointIdentifier: string;
|
.json<{
|
||||||
connectorId: number;
|
chargePointIdentifier: string;
|
||||||
idTag: string;
|
connectorId: number;
|
||||||
}>().catch(() => null);
|
idTag: string;
|
||||||
|
}>()
|
||||||
|
.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,18 +141,74 @@ 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) => {
|
||||||
...r.transaction,
|
const isActive = !r.transaction.stopTimestamp;
|
||||||
chargePointIdentifier: r.chargePointIdentifier,
|
const latestMeterWh = isActive ? (latestMeterMap.get(r.transaction.id) ?? null) : null;
|
||||||
connectorNumber: r.connectorNumber,
|
const liveEnergyWh =
|
||||||
idTagUserId: r.idTagUserId,
|
latestMeterWh != null ? latestMeterWh - r.transaction.startMeterValue : null;
|
||||||
idTagUserName: r.idTagUserName,
|
|
||||||
energyWh:
|
// Estimated cost: only for fixed pricing (TOU requires full interval analysis)
|
||||||
r.transaction.stopMeterValue != null
|
let estimatedCost: number | null = null;
|
||||||
? r.transaction.stopMeterValue - r.transaction.startMeterValue
|
if (
|
||||||
: null,
|
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,
|
total,
|
||||||
page,
|
page,
|
||||||
totalPages: Math.max(1, Math.ceil(total / limit)),
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user