419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
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 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>();
|
|
|
|
/**
|
|
* POST /api/transactions/remote-start
|
|
* Send RemoteStartTransaction to a charge point.
|
|
* Non-admin users can only use their own id-tags.
|
|
*/
|
|
app.post("/remote-start", async (c) => {
|
|
const currentUser = c.get("user");
|
|
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);
|
|
|
|
if (
|
|
!body ||
|
|
!body.chargePointIdentifier?.trim() ||
|
|
!Number.isInteger(body.connectorId) ||
|
|
body.connectorId < 1 ||
|
|
!body.idTag?.trim()
|
|
) {
|
|
return c.json(
|
|
{ error: "chargePointIdentifier, connectorId (>=1), and idTag are required" },
|
|
400,
|
|
);
|
|
}
|
|
|
|
// Non-admin: verify idTag belongs to current user
|
|
if (currentUser.role !== "admin") {
|
|
const [tag] = await db
|
|
.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);
|
|
}
|
|
|
|
// 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
|
|
const [cp] = await db
|
|
.select({ id: chargePoint.id, registrationStatus: chargePoint.registrationStatus })
|
|
.from(chargePoint)
|
|
.where(eq(chargePoint.chargePointIdentifier, body.chargePointIdentifier.trim()))
|
|
.limit(1);
|
|
|
|
if (!cp) return c.json({ error: "ChargePoint not found" }, 404);
|
|
if (cp.registrationStatus !== "Accepted") {
|
|
return c.json({ error: "ChargePoint is not accepted" }, 400);
|
|
}
|
|
|
|
// Require the charge point to be online
|
|
const ws = ocppConnections.get(body.chargePointIdentifier.trim());
|
|
if (!ws) return c.json({ error: "ChargePoint is offline" }, 503);
|
|
|
|
const uniqueId = crypto.randomUUID();
|
|
ws.send(
|
|
JSON.stringify([
|
|
OCPP_MESSAGE_TYPE.CALL,
|
|
uniqueId,
|
|
"RemoteStartTransaction",
|
|
{ connectorId: body.connectorId, idTag: body.idTag.trim() },
|
|
]),
|
|
);
|
|
|
|
console.log(
|
|
`[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` +
|
|
`connector=${body.connectorId} idTag=${body.idTag} user=${currentUser.id}`,
|
|
);
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
/** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */
|
|
app.get("/", async (c) => {
|
|
const page = Math.max(1, Number(c.req.query("page") ?? 1));
|
|
const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20)));
|
|
const status = c.req.query("status"); // 'active' | 'completed'
|
|
const chargePointId = c.req.query("chargePointId");
|
|
const offset = (page - 1) * limit;
|
|
|
|
const db = useDrizzle();
|
|
const currentUser = c.get("user");
|
|
const isAdmin = currentUser?.role === "admin";
|
|
|
|
const statusCondition =
|
|
status === "active"
|
|
? isNull(transaction.stopTimestamp)
|
|
: status === "completed"
|
|
? isNotNull(transaction.stopTimestamp)
|
|
: undefined;
|
|
|
|
// For non-admin users, restrict to transactions matching their id-tags
|
|
const userCondition =
|
|
!isAdmin && currentUser
|
|
? sql`${transaction.idTag} in (select id_tag from id_tag where user_id = ${currentUser.id})`
|
|
: undefined;
|
|
|
|
const cpCondition = chargePointId ? eq(transaction.chargePointId, chargePointId) : undefined;
|
|
|
|
const whereClause = and(statusCondition, userCondition, cpCondition);
|
|
|
|
const [{ total }] = await db
|
|
.select({ total: sql<number>`count(*)::int` })
|
|
.from(transaction)
|
|
.where(whereClause);
|
|
|
|
const rows = await db
|
|
.select({
|
|
transaction,
|
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
|
chargePointDeviceName: chargePoint.deviceName,
|
|
feePerKwh: chargePoint.feePerKwh,
|
|
pricingMode: chargePoint.pricingMode,
|
|
connectorNumber: connector.connectorId,
|
|
idTagUserId: idTag.userId,
|
|
idTagUserName: user.name,
|
|
})
|
|
.from(transaction)
|
|
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
|
.leftJoin(connector, eq(transaction.connectorId, connector.id))
|
|
.leftJoin(idTag, eq(transaction.idTag, idTag.idTag))
|
|
.leftJoin(user, eq(idTag.userId, user.id))
|
|
.where(whereClause)
|
|
.orderBy(desc(transaction.startTimestamp))
|
|
.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<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({
|
|
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,
|
|
chargePointDeviceName: r.chargePointDeviceName,
|
|
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)),
|
|
});
|
|
});
|
|
|
|
/** GET /api/transactions/:id */
|
|
app.get("/:id", async (c) => {
|
|
const db = useDrizzle();
|
|
const id = Number(c.req.param("id"));
|
|
|
|
const [row] = await db
|
|
.select({
|
|
transaction,
|
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
|
chargePointDeviceName: chargePoint.deviceName,
|
|
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,
|
|
chargePointDeviceName: row.chargePointDeviceName,
|
|
connectorNumber: row.connectorNumber,
|
|
energyWh:
|
|
row.transaction.stopMeterValue != null
|
|
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
|
: null,
|
|
liveEnergyWh,
|
|
estimatedCost,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* POST /api/transactions/:id/stop
|
|
* Manually stop an active transaction.
|
|
* 1. If the charge point is connected, send OCPP RemoteStopTransaction.
|
|
* 2. In either case (online or offline), settle the transaction in the DB immediately
|
|
* so the record is always finalised from the admin side.
|
|
*/
|
|
app.post("/:id/stop", async (c) => {
|
|
const db = useDrizzle();
|
|
const id = Number(c.req.param("id"));
|
|
|
|
// Load the transaction
|
|
const [row] = await db
|
|
.select({
|
|
transaction,
|
|
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
|
feePerKwh: chargePoint.feePerKwh,
|
|
})
|
|
.from(transaction)
|
|
.leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id))
|
|
.where(eq(transaction.id, id))
|
|
.limit(1);
|
|
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409);
|
|
|
|
const now = dayjs();
|
|
|
|
// Try to send RemoteStopTransaction via OCPP if the charge point is online
|
|
const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null;
|
|
if (ws) {
|
|
const uniqueId = crypto.randomUUID();
|
|
ws.send(
|
|
JSON.stringify([
|
|
OCPP_MESSAGE_TYPE.CALL,
|
|
uniqueId,
|
|
"RemoteStopTransaction",
|
|
{ transactionId: row.transaction.id },
|
|
]),
|
|
);
|
|
console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`);
|
|
}
|
|
|
|
// Settle in DB regardless (charge point may be offline or slow to respond)
|
|
// Use startMeterValue as stopMeterValue when the real value is unknown (offline case)
|
|
const stopMeterValue = row.transaction.startMeterValue;
|
|
const energyWh = 0; // cannot know actual energy without stop meter value
|
|
const feePerKwh = row.feePerKwh ?? 0;
|
|
const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0;
|
|
|
|
const [updated] = await db
|
|
.update(transaction)
|
|
.set({
|
|
stopTimestamp: now.toDate(),
|
|
stopMeterValue,
|
|
stopReason: "Remote",
|
|
chargeAmount: feeFen,
|
|
updatedAt: now.toDate(),
|
|
})
|
|
.where(eq(transaction.id, id))
|
|
.returning();
|
|
|
|
if (feeFen > 0) {
|
|
await db
|
|
.update(idTag)
|
|
.set({
|
|
balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`,
|
|
updatedAt: now.toDate(),
|
|
})
|
|
.where(eq(idTag.idTag, row.transaction.idTag));
|
|
}
|
|
|
|
return c.json({
|
|
...updated,
|
|
chargePointIdentifier: row.chargePointIdentifier,
|
|
online: !!ws,
|
|
energyWh,
|
|
});
|
|
});
|
|
|
|
/** DELETE /api/transactions/:id — delete a transaction record */
|
|
app.delete("/:id", async (c) => {
|
|
if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403);
|
|
const db = useDrizzle();
|
|
const id = Number(c.req.param("id"));
|
|
|
|
const [row] = await db
|
|
.select({ transaction, connectorId: transaction.connectorId })
|
|
.from(transaction)
|
|
.where(eq(transaction.id, id))
|
|
.limit(1);
|
|
|
|
if (!row) return c.json({ error: "Not found" }, 404);
|
|
|
|
// If the transaction is still active, reset the connector to Available
|
|
if (!row.transaction.stopTimestamp) {
|
|
await db
|
|
.update(connector)
|
|
.set({ status: "Available", updatedAt: dayjs().toDate() })
|
|
.where(eq(connector.id, row.transaction.connectorId));
|
|
}
|
|
|
|
await db.delete(transaction).where(eq(transaction.id, id));
|
|
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
export default app;
|