feat: add ETC records and bill drilldowns

This commit is contained in:
kkfluous
2026-08-07 16:19:19 +08:00
parent 47a4474608
commit 263d8c6245
10 changed files with 612 additions and 17 deletions
+194
View File
@@ -9,10 +9,14 @@ import {
parseHydrogenCustomerName,
parseHydrogenStationId,
parseEnergyDate,
parseEtcLimit,
parseEtcPage,
parseEtcSearch,
type HydrogenCustomerKind,
} from './query.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import { observeDataSource } from '../../source-telemetry.js';
const app = new Hono();
@@ -867,4 +871,194 @@ app.get('/etc/overview', async (c) => {
return c.json(data);
});
app.get('/etc/records', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('trans_time >= ?');
params.push(startDate);
}
if (endDate) {
where.push('trans_time < DATE_ADD(?, INTERVAL 1 DAY)');
params.push(endDate);
}
if (search) {
where.push('(plate_number LIKE ? OR customer_name LIKE ? OR record_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
SUM(toll_amount) AS tollAmount,
SUM(service_fee) AS serviceFee,
SUM(total_amount) AS totalAmount
FROM etc_toll_record
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
record_code AS recordCode,
plate_number AS plate,
customer_name AS customerName,
entry_station_name AS entryStation,
exit_station_name AS exitStation,
DATE_FORMAT(trans_time, '%Y-%m-%d %H:%i:%s') AS transTime,
toll_amount AS tollAmount,
service_fee AS serviceFee,
total_amount AS totalAmount,
cost_type AS costType,
contract_matched AS contractMatched,
review_status AS reviewStatus
FROM etc_toll_record
WHERE ${whereSql}
ORDER BY trans_time DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
tollAmount: Math.round((Number(summary.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(summary.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(summary.totalAmount) || 0) * 100) / 100,
},
items: detailRows.map(row => ({
id: Number(row.id),
recordCode: String(row.recordCode),
plate: String(row.plate),
customerName: row.customerName ? String(row.customerName) : null,
entryStation: row.entryStation ? String(row.entryStation) : null,
exitStation: row.exitStation ? String(row.exitStation) : null,
transTime: String(row.transTime),
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
totalAmount: Math.round((Number(row.totalAmount) || 0) * 100) / 100,
costType: Number(row.costType) || 0,
contractMatched: Number(row.contractMatched) === 1,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
app.get('/etc/bills', async (c) => {
const page = parseEtcPage(c.req.query('page'));
const limit = parseEtcLimit(c.req.query('limit'));
const search = parseEtcSearch(c.req.query('search'));
if (search === null) return c.json({ error: 'search 最多 128 个字符' }, 400);
const startParam = c.req.query('startDate');
const endParam = c.req.query('endDate');
const parsedStart = startParam === undefined ? undefined : parseEnergyDate(startParam);
const parsedEnd = endParam === undefined ? undefined : parseEnergyDate(endParam);
if (parsedStart === null || parsedEnd === null) {
return c.json({ error: 'startDate/endDate 必须是有效的 YYYY-MM-DD 日期' }, 400);
}
const startDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedEnd : parsedStart;
const endDate = parsedStart && parsedEnd && parsedStart > parsedEnd ? parsedStart : parsedEnd;
const where = ["del_flag = '0'"];
const params: Array<string | number> = [];
if (startDate) {
where.push('bill_period_end >= ?');
params.push(startDate);
}
if (endDate) {
where.push('bill_period_start <= ?');
params.push(endDate);
}
if (search) {
where.push('(customer_name LIKE ? OR bill_code LIKE ?)');
const pattern = `%${search}%`;
params.push(pattern, pattern);
}
const whereSql = where.join(' AND ');
const offset = (page - 1) * limit;
const [[summaryRows], [detailRows]] = await observeDataSource('etcDatabase', () => Promise.all([
pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS total,
SUM(receivable_amount) AS receivableAmount,
SUM(paid_amount) AS paidAmount
FROM energy_etc_bill
WHERE ${whereSql}`,
params,
),
pool.query<RowDataPacket[]>(
`SELECT id,
bill_code AS billCode,
customer_name AS customerName,
DATE_FORMAT(bill_period_start, '%Y-%m-%d') AS periodStart,
DATE_FORMAT(bill_period_end, '%Y-%m-%d') AS periodEnd,
total_toll_count AS tollCount,
total_toll_amount AS tollAmount,
total_service_fee AS serviceFee,
receivable_amount AS receivableAmount,
paid_amount AS paidAmount,
payment_status AS paymentStatus,
review_status AS reviewStatus
FROM energy_etc_bill
WHERE ${whereSql}
ORDER BY bill_period_end DESC, id DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]));
const summary = summaryRows[0] ?? {};
const total = Number(summary.total) || 0;
return c.json({
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
filters: { startDate: startDate || null, endDate: endDate || null, search },
summary: {
receivableAmount: Math.round((Number(summary.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(summary.paidAmount) || 0) * 100) / 100,
},
items: detailRows.map(row => ({
id: Number(row.id),
billCode: String(row.billCode),
customerName: row.customerName ? String(row.customerName) : null,
periodStart: String(row.periodStart),
periodEnd: String(row.periodEnd),
tollCount: Number(row.tollCount) || 0,
tollAmount: Math.round((Number(row.tollAmount) || 0) * 100) / 100,
serviceFee: Math.round((Number(row.serviceFee) || 0) * 100) / 100,
receivableAmount: Math.round((Number(row.receivableAmount) || 0) * 100) / 100,
paidAmount: Math.round((Number(row.paidAmount) || 0) * 100) / 100,
paymentStatus: Number(row.paymentStatus) || 0,
reviewStatus: Number(row.reviewStatus) || 0,
})),
});
});
export default app;