feat(transactions): add transaction detail page with live energy and cost estimation

feat(transactions): implement active transaction checks and idTag validation
feat(id-tag): enhance idTag card with disabled state for active transactions
fix(transactions): improve error handling and user feedback for transaction actions
This commit is contained in:
2026-03-13 11:51:06 +08:00
parent c8ddaa4dcc
commit 83e6ed2412
7 changed files with 747 additions and 60 deletions

View File

@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull } from "drizzle-orm";
import dayjs from "dayjs";
import { useDrizzle } from "@/lib/db.js";
import { idTag } from "@/db/schema.js";
import { idTag, transaction } from "@/db/schema.js";
import type {
AuthorizeRequest,
AuthorizeResponse,
@@ -19,6 +19,7 @@ import type {
export async function resolveIdTagInfo(
idTagValue: string,
checkBalance = true,
checkConcurrent = true,
): Promise<IdTagInfo> {
const db = useDrizzle();
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
@@ -31,6 +32,17 @@ export async function resolveIdTagInfo(
if (tag.status !== "Accepted") {
return { status: tag.status as IdTagInfo["status"] };
}
// Enforce single active transaction per idTag.
if (checkConcurrent) {
const [activeTx] = await db
.select({ id: transaction.id })
.from(transaction)
.where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp)))
.limit(1);
if (activeTx) return { status: "ConcurrentTx" };
}
// Reject if balance is zero or negative
if (checkBalance && tag.balance <= 0) {
return { status: "Blocked" };

View File

@@ -183,7 +183,9 @@ export async function handleStopTransaction(
);
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
const idTagInfo = payload.idTag
? await resolveIdTagInfo(payload.idTag, false, false)
: undefined;
return { idTagInfo };
}

View File

@@ -2,11 +2,12 @@ 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, meterValue } from "@/db/schema.js";
import { transaction, chargePoint, connector, idTag } 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";
import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js";
import type { HonoEnv } from "@/types/hono.ts";
const app = new Hono<HonoEnv>();
@@ -42,15 +43,33 @@ app.post("/remote-start", async (c) => {
);
}
// Non-admin: verify idTag belongs to current user and is Accepted
// Non-admin: verify idTag belongs to current user
if (currentUser.role !== "admin") {
const [tag] = await db
.select({ status: idTag.status })
.select({ idTag: idTag.idTag })
.from(idTag)
.where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id)))
.limit(1);
if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403);
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
}
// Reuse the same authorization logic as Authorize/StartTransaction.
const tagInfo = await resolveIdTagInfo(body.idTag.trim());
if (tagInfo.status !== "Accepted") {
if (tagInfo.status === "ConcurrentTx") {
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
}
return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400);
}
// One idTag can only have one active transaction at a time.
const [activeTx] = await db
.select({ id: transaction.id })
.from(transaction)
.where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp)))
.limit(1);
if (activeTx) {
return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409);
}
// Verify charge point exists and is Accepted
@@ -224,21 +243,64 @@ app.get("/:id", async (c) => {
.select({
transaction,
chargePointIdentifier: chargePoint.chargePointIdentifier,
connectorNumber: connector.connectorId,
feePerKwh: chargePoint.feePerKwh,
pricingMode: chargePoint.pricingMode,
})
.from(transaction)
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
.leftJoin(connector, eq(transaction.connectorId, connector.id))
.where(eq(transaction.id, id))
.limit(1);
if (!row) return c.json({ error: "Not found" }, 404);
let liveEnergyWh: number | null = null;
let estimatedCost: number | null = null;
// For active transactions, return live estimated energy/cost like the list endpoint.
if (!row.transaction.stopTimestamp) {
const latestRows = await db.execute<{
sampled_values: SampledValue[];
}>(sql`
SELECT sampled_values
FROM meter_value
WHERE transaction_id = ${id}
ORDER BY timestamp DESC
LIMIT 1
`);
const latest = latestRows.rows[0];
if (latest) {
const svList = latest.sampled_values as SampledValue[];
const energySv = svList.find(
(sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase,
);
if (energySv != null) {
const raw = parseFloat(energySv.value);
if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) {
const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw;
liveEnergyWh = latestMeterWh - row.transaction.startMeterValue;
if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) {
estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000);
}
}
}
}
}
return c.json({
...row.transaction,
chargePointIdentifier: row.chargePointIdentifier,
connectorNumber: row.connectorNumber,
energyWh:
row.transaction.stopMeterValue != null
? row.transaction.stopMeterValue - row.transaction.startMeterValue
: null,
liveEnergyWh,
estimatedCost,
});
});