refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
+8
View File
@@ -0,0 +1,8 @@
export const HYDROGEN_MIN_DATE = '2024-01-01';
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
export const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
export const HYDROGEN_LOCAL = `refuel_time`;
export const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
export const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
export const ELECTRIC_LOCAL = `charging_start_time`;
+191
View File
@@ -0,0 +1,191 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type pool from '../../db.js';
import type { cached } from './cache.js';
import {
dateRangeClause,
enumerateDateRange,
resolveDateRange,
type CustomerKind,
type EnergyDateRangeKind as Range,
} from './query-model.js';
export interface ElectricDependencies {
pool: Pick<typeof pool, 'query'>;
cached: typeof cached;
}
// 电能总览:KPI + 本月每日柱图数据。数据源:bi_ele_charge_record。
export function registerElectricOverviewRoute(
app: Hono,
{ pool, cached }: ElectricDependencies,
) {
app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
const totalKwh = Number(k.totalKwh) || 0;
const totalFee = Number(k.totalFee) || 0;
const monthKwh = Number(k.monthKwh) || 0;
const monthFee = Number(k.monthFee) || 0;
const todayKwh = Number(k.todayKwh) || 0;
const todayFee = Number(k.todayFee) || 0;
// 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
// 若本月无数据,降级展示最近一个有数据的自然月
let trend = trendRows;
if (trend.length === 0) {
const [fallback] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
trend = fallback;
}
const trendArr = trend.map(r => ({
date: r.date as string,
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
chainPct: 0,
}));
for (let i = 1; i < trendArr.length; i++) {
const prev = trendArr[i - 1].kwh;
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
}
let todayChainPct = 0;
if (todayKwh > 0) {
const [prevRow] = await pool.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
const prevKwh = Number(prevRow[0]?.kwh) || 0;
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
}
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
};
}, { force });
return c.json(data);
});
}
// 电能每日:月份分组 + 日级行。支持 thisWeek / thisMonth / last15,缺失日期补零。
export function registerElectricMonthlyRoute(
app: Hono,
{ pool, cached }: ElectricDependencies,
) {
app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
let kindClause = '1=1';
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${kindClause}
AND ${dateRangeClause('start_time')}
GROUP BY date`,
[dateRange.start, dateRange.end],
);
// 实际数据 map
const dataMap = new Map<string, { kwh: number; fee: number }>();
for (const r of rows) {
dataMap.set(r.date as string, {
kwh: Number(r.kwh) || 0,
fee: Number(r.fee) || 0,
});
}
// 补零:枚举 range 全部日期
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const d = dataMap.get(date);
return {
date,
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
fee: d ? Math.round(d.fee * 100) / 100 : 0,
};
});
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
const monthMap = new Map<string, typeof fullDays>();
for (const d of fullDays) {
const m = d.date.slice(0, 7);
if (!monthMap.has(m)) monthMap.set(m, []);
monthMap.get(m)!.push(d);
}
const months = Array.from(monthMap.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, days]) => {
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
const chain = new Map<string, number>();
let prev = 0;
for (const d of asc) {
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
prev = d.kwh;
}
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
const rowsWithChain = desc.map(d => ({
date: d.date,
kwh: d.kwh,
fee: d.fee,
chainPct: chain.get(d.date) ?? 0,
}));
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
const feeSum = days.reduce((s, d) => s + d.fee, 0);
return {
month,
kwh: Math.round(kwhSum * 100) / 100,
fee: Math.round(feeSum * 100) / 100,
rows: rowsWithChain,
};
});
return months;
}, { force });
return c.json(data);
});
}
+145
View File
@@ -0,0 +1,145 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
import {
customerClause,
dateRangeClause,
enumerateDateRange,
resolveDateRange,
type CustomerKind,
type EnergyDateRangeKind as Range,
} from './query-model.js';
export interface HydrogenDailyDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
// 氢能每日:日期范围 + 客户类型 + 站点级下钻。
export function registerHydrogenDailyRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenDailyDependencies,
) {
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
);
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
const flat: StationRow[] = stationRows.map(r => ({
date: r.d as string,
stationId: Number(r.stationId),
name: r.stationName as string,
kg: Number(r.kg) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
const dayChainPct = new Map<string, number>();
let prev = 0;
for (const d of dates) {
const cur = dayMap.get(d)!.totalKg;
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
prev = cur;
}
// 站点级环比:按 stationId 分组按日期升序
const stationPrev = new Map<number, number>();
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
// 需要按 stationId 分组排序
const byStation = new Map<number, StationRow[]>();
for (const s of flat) {
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
byStation.get(s.stationId)!.push(s);
}
for (const [, list] of byStation) {
list.sort((a, b) => a.date.localeCompare(b.date));
let p = 0;
for (const r of list) {
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
p = r.kg;
}
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
};
});
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
let prevKg = 0;
for (const d of ascDays) {
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
prevKg = d.totalKg;
}
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
return c.json(data);
});
}
@@ -0,0 +1,273 @@
import type { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type hydrogenPool from '../../hydrogen-db.js';
import type { cached } from './cache.js';
import {
HYDROGEN_BASE_WHERE,
HYDROGEN_BASE_WHERE_B,
HYDROGEN_LOCAL,
HYDROGEN_MIN_DATE,
HYDROGEN_TABLE,
} from './constants.js';
export interface HydrogenOverviewDependencies {
hydrogenPool: Pick<typeof hydrogenPool, 'query'>;
cached: typeof cached;
}
// 氢能总览:KPI + Top5 + 区域占比。
export function registerHydrogenOverviewRoute(
app: Hono,
{ hydrogenPool, cached }: HydrogenOverviewDependencies,
) {
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
);
const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
const top = regionRows.slice(0, TOP_REGIONS);
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
const regions = [
...top.map(r => ({
region: r.region as string,
kg: Number(r.kg) || 0,
share: (Number(r.kg) || 0) / totalKg,
})),
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
return c.json(data);
});
}
+13 -637
View File
@@ -1,10 +1,15 @@
import { Hono } from 'hono';
import type { RowDataPacket } from 'mysql2';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import pool from '../../db.js';
import hydrogenPool from '../../hydrogen-db.js';
import { cached } from './cache.js';
import type { AuthUser } from '../../auth/types.js';
import { canAccessEnergy } from '../../auth/types.js';
import {
registerElectricMonthlyRoute,
registerElectricOverviewRoute,
} from './electric.js';
import { registerHydrogenDailyRoute } from './hydrogen-daily.js';
import { registerHydrogenOverviewRoute } from './hydrogen-overview.js';
const app = new Hono();
@@ -18,640 +23,11 @@ app.use('*', async (c, next) => {
return next();
});
const HYDROGEN_MIN_DATE = '2024-01-01';
const cacheDependency = { cached };
// hydrogen_fuel_ledger.refuel_time 已是业务本地时间字面值,直接使用即可(不再 +8 小时)
const HYDROGEN_TABLE = 'hydrogen_fuel_ledger';
const HYDROGEN_LOCAL = `refuel_time`;
const HYDROGEN_BASE_WHERE = `del_flag = '0'`;
const HYDROGEN_BASE_WHERE_B = `b.del_flag = '0'`;
const ELECTRIC_LOCAL = `charging_start_time`;
type CustomerKind = 'external' | 'lingniu' | 'all';
// 新账本 hydrogen_fuel_ledger 当前只承载羚牛车辆订单;外部车辆数据源待接入。
function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
return '1=1';
}
type Range = 'thisWeek' | 'thisMonth' | 'last15';
interface DateRange {
start: string;
end: string;
}
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
function fmtYmd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function addDays(d: Date, days: number): Date {
const next = new Date(d);
next.setDate(next.getDate() + days);
return next;
}
function parseYmd(value: string | undefined): string | null {
if (!value || !YMD_RE.test(value)) return null;
const d = new Date(`${value}T00:00:00`);
return Number.isNaN(d.getTime()) ? null : value;
}
function resolveDateRange(range: Range, startParam?: string, endParam?: string): DateRange {
const customStart = parseYmd(startParam);
const customEnd = parseYmd(endParam);
if (customStart && customEnd) {
return customStart <= customEnd
? { start: customStart, end: customEnd }
: { start: customEnd, end: customStart };
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (range === 'thisWeek') {
const day = today.getDay() || 7;
return { start: fmtYmd(addDays(today, -(day - 1))), end: fmtYmd(today) };
}
if (range === 'thisMonth') {
return { start: fmtYmd(new Date(today.getFullYear(), today.getMonth(), 1)), end: fmtYmd(today) };
}
return { start: fmtYmd(addDays(today, -14)), end: fmtYmd(today) };
}
function dateRangeClause(localExpr: string): string {
return `${localExpr} >= ? AND ${localExpr} < DATE_ADD(?, INTERVAL 1 DAY)`;
}
function rangeClause(localExpr: string, range: Range): string {
switch (range) {
case 'thisWeek': return `YEARWEEK(${localExpr}, 1) = YEARWEEK(CURDATE(), 1)`;
case 'thisMonth': return `DATE_FORMAT(${localExpr}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')`;
case 'last15': return `DATE(${localExpr}) BETWEEN DATE_SUB(CURDATE(), INTERVAL 14 DAY) AND CURDATE()`;
}
}
/** 列出某 range 在当前时点下的全部日期(YYYY-MM-DD),用于补零 */
function enumerateDates(range: Range): string[] {
const { start, end } = resolveDateRange(range);
return enumerateDateRange(start, end);
}
function enumerateDateRange(startYmd: string, endYmd: string): string[] {
const result: string[] = [];
const cur = new Date(`${startYmd}T00:00:00`);
const end = new Date(`${endYmd}T00:00:00`);
while (cur <= end) {
result.push(fmtYmd(cur));
cur.setDate(cur.getDate() + 1);
}
return result;
}
// =========================================================
// 氢能 总览:KPI + Top5 + 区域占比
// =========================================================
app.get('/hydrogen/overview', async (c) => {
const yearParam = c.req.query('year');
const force = c.req.query('force') === '1';
const today = new Date();
const todayYear = today.getFullYear();
const requestedYear = yearParam ? Number(yearParam) || todayYear : todayYear;
const data = await cached(`hydrogen/overview?year=${requestedYear}`, async () => {
// 可选年份(数据自 HYDROGEN_MIN_DATE 起)
const [yearListRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DISTINCT YEAR(${HYDROGEN_LOCAL}) AS y
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?
ORDER BY y DESC`,
[HYDROGEN_MIN_DATE],
);
const availableYears = yearListRows.map(r => Number(r.y)).filter(y => y > 0);
const year = availableYears.includes(requestedYear) ? requestedYear : (availableYears[0] ?? todayYear);
const isCurrentYear = year === todayYear;
// KPI(按 year 分桶;月/日仅在 isCurrentYear 时取本月/今日)
const [kpiRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN amount_kg ELSE 0 END) AS yearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN cost_total ELSE 0 END) AS yearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS yearCustomerCost,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ?
THEN fee_total ELSE 0 END) AS yearRevenue,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN amount_kg ELSE 0 END) AS ourYearKg,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND COALESCE(customer_price, 0) <= 0 AND COALESCE(fee_total, 0) <= 0
THEN cost_total ELSE 0 END) AS ourYearFee,
SUM(CASE WHEN YEAR(${HYDROGEN_LOCAL}) = ? AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN amount_kg ELSE 0 END) AS customerYearKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN amount_kg ELSE 0 END) AS monthKg,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN cost_total ELSE 0 END) AS monthFee,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS monthCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee_total ELSE 0 END) AS monthRevenue,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN amount_kg ELSE 0 END) AS todayKg,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN cost_total ELSE 0 END) AS todayFee,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
AND (COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0)
THEN cost_total ELSE 0 END) AS todayCustomerCost,
SUM(CASE WHEN ? = 1 AND DATE(${HYDROGEN_LOCAL}) = CURDATE()
THEN fee_total ELSE 0 END) AS todayRevenue,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN amount_kg ELSE 0 END) AS lingniuBornKg,
SUM(CASE WHEN vehicle_id IS NOT NULL
THEN cost_total ELSE 0 END) AS lingniuBornFee
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE} AND ${HYDROGEN_LOCAL} >= ?`,
[year, year, year, year, year, year, year,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0, isCurrentYear ? 1 : 0,
HYDROGEN_MIN_DATE],
);
const k = kpiRows[0] ?? {};
const yearFee = Number(k.yearFee) || 0;
const yearCustomerCost = Number(k.yearCustomerCost) || 0;
const yearRevenue = Number(k.yearRevenue) || 0;
const monthFee = Number(k.monthFee) || 0;
const monthCustomerCost = Number(k.monthCustomerCost) || 0;
const monthRevenue = Number(k.monthRevenue) || 0;
const todayFee = Number(k.todayFee) || 0;
const todayCustomerCost = Number(k.todayCustomerCost) || 0;
const todayRevenue = Number(k.todayRevenue) || 0;
const kpi = {
yearKg: Number(k.yearKg) || 0,
yearFee,
yearRevenue,
yearProfit: yearRevenue - yearCustomerCost,
ourYearKg: Number(k.ourYearKg) || 0,
ourYearFee: Number(k.ourYearFee) || 0,
customerYearKg: Number(k.customerYearKg) || 0,
monthKg: Number(k.monthKg) || 0,
monthFee,
monthRevenue,
monthProfit: monthRevenue - monthCustomerCost,
todayKg: Number(k.todayKg) || 0,
todayFee,
todayRevenue,
todayProfit: todayRevenue - todayCustomerCost,
lingniuBornKg: Number(k.lingniuBornKg) || 0,
lingniuBornFee: Number(k.lingniuBornFee) || 0,
};
// Top5 加氢站(指定年份)
const [top5Rows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.cost_total) AS fee
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC
LIMIT 5`,
[HYDROGEN_MIN_DATE, year],
);
const top5KgSum = kpi.yearKg || 1;
const top5 = top5Rows.map((r, i) => ({
rank: i + 1,
name: r.name as string,
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
share: (Number(r.kg) || 0) / top5KgSum,
}));
// 加氢站全量汇总(同年所有站,按加氢量降序)
const [stationFullRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT b.station_id AS id,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN b.station_id IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', b.station_id) END) AS name,
SUM(b.amount_kg) AS kg,
SUM(b.fee_total) AS revenue
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
GROUP BY b.station_id
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const stationKgSum = stationFullRows.reduce((s, r) => s + (Number(r.kg) || 0), 0) || 1;
const stationRevSum = stationFullRows.reduce((s, r) => s + (Number(r.revenue) || 0), 0) || 1;
const stations = stationFullRows.map(r => ({
name: r.name as string,
kg: Number(r.kg) || 0,
revenue: Number(r.revenue) || 0,
share: (Number(r.kg) || 0) / stationKgSum,
revenueShare: (Number(r.revenue) || 0) / stationRevSum,
}));
// 区域占比(按城市,指定年份)— 取前 8,其余合并为"其他"
const [regionRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT region, SUM(kg) AS kg FROM (
SELECT CASE
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%嘉兴%' OR COALESCE(s.station_name, b.station_name, '') LIKE '%平湖%' THEN '嘉兴'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%广州%' THEN '广州'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%佛山%' THEN '佛山'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%成都%' THEN '成都'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%重庆%' THEN '重庆'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%乌鲁木齐%' THEN '乌鲁木齐'
WHEN COALESCE(s.station_name, b.station_name, '') LIKE '%昆山%' THEN '昆山'
ELSE COALESCE(NULLIF(s.station_name, ''), NULLIF(b.station_name, ''), '未知')
END AS region,
b.amount_kg AS kg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${HYDROGEN_BASE_WHERE_B}
AND b.${HYDROGEN_LOCAL} >= ?
AND YEAR(b.${HYDROGEN_LOCAL}) = ?
) r
GROUP BY region
ORDER BY kg DESC`,
[HYDROGEN_MIN_DATE, year],
);
const totalKg = regionRows.reduce((sum, r) => sum + (Number(r.kg) || 0), 0) || 1;
const TOP_REGIONS = 8;
const top = regionRows.slice(0, TOP_REGIONS);
const restKg = regionRows.slice(TOP_REGIONS).reduce((s, r) => s + (Number(r.kg) || 0), 0);
const regions = [
...top.map(r => ({
region: r.region as string,
kg: Number(r.kg) || 0,
share: (Number(r.kg) || 0) / totalKg,
})),
...(restKg > 0 ? [{ region: '其他', kg: restKg, share: restKg / totalKg }] : []),
];
// 月度趋势(指定年份内 12 个月,缺失月补 0)含成本/收入/利润
// 利润 = 客户单收入 - 客户单成本(按 customer_price/fee_total 判断客户承担)
const [monthRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(${HYDROGEN_LOCAL}, '%Y-%m') AS m,
ROUND(SUM(amount_kg), 2) AS kg,
ROUND(SUM(cost_total), 2) AS fee,
ROUND(SUM(CASE WHEN COALESCE(customer_price, 0) > 0 OR COALESCE(fee_total, 0) > 0 THEN cost_total ELSE 0 END), 2) AS customerCost,
ROUND(SUM(fee_total), 2) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY m
ORDER BY m`,
[HYDROGEN_MIN_DATE, year],
);
const monthMap = new Map<string, { kg: number; fee: number; revenue: number; customerCost: number }>();
for (const r of monthRows) {
monthMap.set(r.m as string, {
kg: Number(r.kg) || 0,
fee: Number(r.fee) || 0,
revenue: Number(r.revenue) || 0,
customerCost: Number(r.customerCost) || 0,
});
}
const lastMonth = isCurrentYear ? today.getMonth() + 1 : 12;
const monthly: { month: string; kg: number; fee: number; revenue: number; profit: number }[] = [];
for (let mi = 1; mi <= lastMonth; mi++) {
const key = `${year}-${String(mi).padStart(2, '0')}`;
const v = monthMap.get(key) || { kg: 0, fee: 0, revenue: 0, customerCost: 0 };
monthly.push({ month: key, kg: v.kg, fee: v.fee, revenue: v.revenue, profit: v.revenue - v.customerCost });
}
// 客户账单 Top(指定年份;按加氢量降序,前 30)
// payer:有客户单价/收入 → 客户承担;否则 → 羚牛承担
const [customerRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT COALESCE(NULLIF(TRIM(customer_name), ''), '未指定客户') AS name,
CASE WHEN MAX(COALESCE(customer_price, 0)) <= 0 AND MAX(COALESCE(fee_total, 0)) <= 0 THEN 'lingniu'
ELSE 'customer' END AS payer,
SUM(amount_kg) AS kg,
SUM(cost_total) AS cost,
SUM(fee_total) AS revenue
FROM ${HYDROGEN_TABLE}
WHERE ${HYDROGEN_BASE_WHERE}
AND ${HYDROGEN_LOCAL} >= ?
AND YEAR(${HYDROGEN_LOCAL}) = ?
GROUP BY name
ORDER BY kg DESC
LIMIT 30`,
[HYDROGEN_MIN_DATE, year],
);
const customers = customerRows.map(r => ({
name: r.name as string,
payer: (r.payer as string) === 'lingniu' ? 'lingniu' as const : 'customer' as const,
kg: Number(r.kg) || 0,
cost: Number(r.cost) || 0,
revenue: Number(r.revenue) || 0,
}));
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
}, { force });
return c.json(data);
});
// =========================================================
// 氢能 每日:日期范围 + 客户类型 + 站点级下钻
// =========================================================
app.get('/hydrogen/daily', async (c) => {
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const customer = (c.req.query('customer') || 'external') as CustomerKind;
const force = c.req.query('force') === '1';
const data = await cached(`hydrogen/daily?start=${dateRange.start}&end=${dateRange.end}&customer=${customer}`, async () => {
const where = [
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
// 站点名 fallback:站点主数据 → 账本冗余站点名 → 未关联站点
// 单价不重算:直接取账本成本价。
const [stationRows] = await hydrogenPool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(b.${HYDROGEN_LOCAL}, '%Y-%m-%d') AS d,
COALESCE(b.station_id, 0) AS stationId,
COALESCE(MAX(s.station_short_name), MAX(s.station_name), MAX(b.station_name),
CASE WHEN MAX(b.station_id) IS NULL THEN '未关联站点'
ELSE CONCAT('未知站点 #', MAX(b.station_id)) END) AS stationName,
ROUND(SUM(b.amount_kg), 2) AS kg,
-- 单价:直接取订单中的成本价(不重算)。MAX 自然忽略 0 元的免费/赠送单
MAX(b.cost_price) AS pricePerKg
FROM ${HYDROGEN_TABLE} b
LEFT JOIN hydrogen_station s ON s.id = b.station_id AND s.del_flag = '0'
WHERE ${where}
GROUP BY d, COALESCE(b.station_id, 0)
ORDER BY d DESC, kg DESC`,
[dateRange.start, dateRange.end],
);
// 站点环比:同站点上一条记录的 kg
// 按 stationId 分组、按日期升序计算
type StationRow = { date: string; stationId: number; name: string; kg: number; pricePerKg: number };
const flat: StationRow[] = stationRows.map(r => ({
date: r.d as string,
stationId: Number(r.stationId),
name: r.stationName as string,
kg: Number(r.kg) || 0,
pricePerKg: Number(r.pricePerKg) || 0,
}));
// 计算日级总量 + 日级环比
const dayMap = new Map<string, { totalKg: number; stations: typeof flat }>();
for (const s of flat) {
if (!dayMap.has(s.date)) dayMap.set(s.date, { totalKg: 0, stations: [] });
const e = dayMap.get(s.date)!;
e.totalKg += s.kg;
e.stations.push(s);
}
const dates = Array.from(dayMap.keys()).sort(); // ASC for chain
const dayChainPct = new Map<string, number>();
let prev = 0;
for (const d of dates) {
const cur = dayMap.get(d)!.totalKg;
dayChainPct.set(d, prev > 0 ? (cur - prev) / prev : 0);
prev = cur;
}
// 站点级环比:按 stationId 分组按日期升序
const stationPrev = new Map<number, number>();
const stationChain = new Map<string, number>(); // key = `${date}|${stationId}`
// 需要按 stationId 分组排序
const byStation = new Map<number, StationRow[]>();
for (const s of flat) {
if (!byStation.has(s.stationId)) byStation.set(s.stationId, []);
byStation.get(s.stationId)!.push(s);
}
for (const [, list] of byStation) {
list.sort((a, b) => a.date.localeCompare(b.date));
let p = 0;
for (const r of list) {
stationChain.set(`${r.date}|${r.stationId}`, p > 0 ? (r.kg - p) / p : 0);
p = r.kg;
}
}
// 补零:列出 range 内全部日期,缺失日期返回 totalKg=0、stations=[]
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const info = dayMap.get(date);
return {
date,
totalKg: info ? Math.round(info.totalKg * 100) / 100 : 0,
chainPct: dayChainPct.get(date) ?? 0,
customerType: customer,
stations: info
? info.stations.slice().sort((a, b) => b.kg - a.kg).map(s => ({
name: s.name,
pricePerKg: Math.round(s.pricePerKg * 100) / 100,
kg: Math.round(s.kg * 100) / 100,
chainPct: stationChain.get(`${s.date}|${s.stationId}`) ?? 0,
}))
: [],
};
});
// 全量日期重算环比(含补零日,0→上一日有值时显示 -100%)
const ascDays = [...fullDays].sort((a, b) => a.date.localeCompare(b.date));
let prevKg = 0;
for (const d of ascDays) {
d.chainPct = prevKg > 0 ? (d.totalKg - prevKg) / prevKg : 0;
prevKg = d.totalKg;
}
// 按日期降序返回
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
return result;
}, { force });
return c.json(data);
});
// =========================================================
// 电能 总览:KPI + 本月每日柱图数据 —— 数据源:bi_ele_charge_record
// =========================================================
app.get('/electric/overview', async (c) => {
const force = c.req.query('force') === '1';
const data = await cached('electric/overview', async () => {
const [kpiRows] = await pool.query<RowDataPacket[]>(
`SELECT
SUM(kwh) AS totalKwh,
SUM(fee) AS totalFee,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN kwh ELSE 0 END) AS monthKwh,
SUM(CASE WHEN DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
THEN fee ELSE 0 END) AS monthFee,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN kwh ELSE 0 END) AS todayKwh,
SUM(CASE WHEN DATE(start_time) = CURDATE() THEN fee ELSE 0 END) AS todayFee
FROM bi_ele_charge_record`,
);
const k = kpiRows[0] ?? {};
const totalKwh = Number(k.totalKwh) || 0;
const totalFee = Number(k.totalFee) || 0;
const monthKwh = Number(k.monthKwh) || 0;
const monthFee = Number(k.monthFee) || 0;
const todayKwh = Number(k.todayKwh) || 0;
const todayFee = Number(k.todayFee) || 0;
// 本月每日(用于柱图)
const [trendRows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')
GROUP BY date
ORDER BY date ASC`,
);
// 若本月无数据,降级展示最近一个有数据的自然月
let trend = trendRows;
if (trend.length === 0) {
const [fallback] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE DATE_FORMAT(start_time, '%Y-%m') = (
SELECT DATE_FORMAT(MAX(start_time), '%Y-%m') FROM bi_ele_charge_record
)
GROUP BY date
ORDER BY date ASC`,
);
trend = fallback;
}
const trendArr = trend.map(r => ({
date: r.date as string,
kwh: Math.round((Number(r.kwh) || 0) * 100) / 100,
fee: Math.round((Number(r.fee) || 0) * 100) / 100,
chainPct: 0,
}));
for (let i = 1; i < trendArr.length; i++) {
const prev = trendArr[i - 1].kwh;
trendArr[i].chainPct = prev > 0 ? (trendArr[i].kwh - prev) / prev : 0;
}
let todayChainPct = 0;
if (todayKwh > 0) {
const [prevRow] = await pool.query<RowDataPacket[]>(
`SELECT SUM(kwh) AS kwh
FROM bi_ele_charge_record
WHERE DATE(start_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)`,
);
const prevKwh = Number(prevRow[0]?.kwh) || 0;
todayChainPct = prevKwh > 0 ? (todayKwh - prevKwh) / prevKwh : 0;
}
return {
kpi: { totalKwh, totalFee, monthKwh, monthFee, todayKwh, todayFee, todayChainPct },
trend: trendArr,
};
}, { force });
return c.json(data);
});
// =========================================================
// 电能 每日:月份分组 + 日级行 —— 数据源:bi_ele_charge_record
// 支持 range 参数(thisWeek / thisMonth / last15
// 缺失日期补零
// =========================================================
app.get('/electric/monthly', async (c) => {
const customer = (c.req.query('customer') || 'lingniu') as CustomerKind;
const range = (c.req.query('range') || 'last15') as Range;
const dateRange = resolveDateRange(range, c.req.query('startDate'), c.req.query('endDate'));
const force = c.req.query('force') === '1';
const data = await cached(`electric/monthly?customer=${customer}&start=${dateRange.start}&end=${dateRange.end}`, async () => {
// bi_ele_charge_record 用 vehicle_kind 区分:internal=我司,external=外部
let kindClause = '1=1';
if (customer === 'lingniu') kindClause = `vehicle_kind = 'internal'`;
if (customer === 'external') kindClause = `vehicle_kind = 'external'`;
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT DATE_FORMAT(start_time, '%Y-%m-%d') AS date,
SUM(kwh) AS kwh,
SUM(fee) AS fee
FROM bi_ele_charge_record
WHERE ${kindClause}
AND ${dateRangeClause('start_time')}
GROUP BY date`,
[dateRange.start, dateRange.end],
);
// 实际数据 map
const dataMap = new Map<string, { kwh: number; fee: number }>();
for (const r of rows) {
dataMap.set(r.date as string, {
kwh: Number(r.kwh) || 0,
fee: Number(r.fee) || 0,
});
}
// 补零:枚举 range 全部日期
const allDates = enumerateDateRange(dateRange.start, dateRange.end);
const fullDays = allDates.map(date => {
const d = dataMap.get(date);
return {
date,
kwh: d ? Math.round(d.kwh * 100) / 100 : 0,
fee: d ? Math.round(d.fee * 100) / 100 : 0,
};
});
// 按月份分组(asc 内日期倒序,但月份分组按 desc)
const monthMap = new Map<string, typeof fullDays>();
for (const d of fullDays) {
const m = d.date.slice(0, 7);
if (!monthMap.has(m)) monthMap.set(m, []);
monthMap.get(m)!.push(d);
}
const months = Array.from(monthMap.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, days]) => {
const asc = [...days].sort((a, b) => a.date.localeCompare(b.date));
const chain = new Map<string, number>();
let prev = 0;
for (const d of asc) {
chain.set(d.date, prev > 0 ? (d.kwh - prev) / prev : 0);
prev = d.kwh;
}
const desc = [...days].sort((a, b) => b.date.localeCompare(a.date));
const rowsWithChain = desc.map(d => ({
date: d.date,
kwh: d.kwh,
fee: d.fee,
chainPct: chain.get(d.date) ?? 0,
}));
const kwhSum = days.reduce((s, d) => s + d.kwh, 0);
const feeSum = days.reduce((s, d) => s + d.fee, 0);
return {
month,
kwh: Math.round(kwhSum * 100) / 100,
fee: Math.round(feeSum * 100) / 100,
rows: rowsWithChain,
};
});
return months;
}, { force });
return c.json(data);
});
registerHydrogenOverviewRoute(app, { ...cacheDependency, hydrogenPool });
registerHydrogenDailyRoute(app, { ...cacheDependency, hydrogenPool });
registerElectricOverviewRoute(app, { ...cacheDependency, pool });
registerElectricMonthlyRoute(app, { ...cacheDependency, pool });
export default app;
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
customerClause,
dateRangeClause,
enumerateDateRange,
resolveDateRange,
} from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
assert.equal(customerClause('all'), '1=1');
});
test('能源日期快捷区间继续按本地自然日计算', () => {
const now = new Date(2026, 7, 12, 23, 30);
assert.deepEqual(resolveDateRange('thisWeek', undefined, undefined, now), {
start: '2026-08-10',
end: '2026-08-12',
});
assert.deepEqual(resolveDateRange('thisMonth', undefined, undefined, now), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.deepEqual(resolveDateRange('last15', undefined, undefined, now), {
start: '2026-07-29',
end: '2026-08-12',
});
});
test('合法自定义日期优先并在倒置时交换边界', () => {
assert.deepEqual(resolveDateRange('last15', '2026-08-12', '2026-08-01'), {
start: '2026-08-01',
end: '2026-08-12',
});
assert.deepEqual(resolveDateRange('last15', 'invalid', '2026-08-01', new Date(2026, 7, 12)), {
start: '2026-07-29',
end: '2026-08-12',
});
});
test('补零日期序列包含首尾且 SQL 日期上界保持开区间', () => {
assert.deepEqual(enumerateDateRange('2026-08-10', '2026-08-12'), [
'2026-08-10',
'2026-08-11',
'2026-08-12',
]);
assert.equal(
dateRangeClause('b.refuel_time'),
'b.refuel_time >= ? AND b.refuel_time < DATE_ADD(?, INTERVAL 1 DAY)',
);
});
+76
View File
@@ -0,0 +1,76 @@
export type CustomerKind = 'external' | 'lingniu' | 'all';
export type EnergyDateRangeKind = 'thisWeek' | 'thisMonth' | 'last15';
export interface EnergyDateRange {
start: string;
end: string;
}
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
return '1=1';
}
function formatYmd(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function parseYmd(value: string | undefined): string | null {
if (!value || !YMD_RE.test(value)) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : value;
}
export function resolveDateRange(
range: EnergyDateRangeKind,
startParam?: string,
endParam?: string,
now: Date = new Date(),
): EnergyDateRange {
const customStart = parseYmd(startParam);
const customEnd = parseYmd(endParam);
if (customStart && customEnd) {
return customStart <= customEnd
? { start: customStart, end: customEnd }
: { start: customEnd, end: customStart };
}
const today = new Date(now);
today.setHours(0, 0, 0, 0);
if (range === 'thisWeek') {
const day = today.getDay() || 7;
return { start: formatYmd(addDays(today, -(day - 1))), end: formatYmd(today) };
}
if (range === 'thisMonth') {
return {
start: formatYmd(new Date(today.getFullYear(), today.getMonth(), 1)),
end: formatYmd(today),
};
}
return { start: formatYmd(addDays(today, -14)), end: formatYmd(today) };
}
export function dateRangeClause(localExpression: string): string {
return `${localExpression} >= ? AND ${localExpression} < DATE_ADD(?, INTERVAL 1 DAY)`;
}
export function enumerateDateRange(startYmd: string, endYmd: string): string[] {
const result: string[] = [];
const cursor = new Date(`${startYmd}T00:00:00`);
const end = new Date(`${endYmd}T00:00:00`);
while (cursor <= end) {
result.push(formatYmd(cursor));
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { Hono } from 'hono';
import app from './index.js';
import {
registerElectricMonthlyRoute,
registerElectricOverviewRoute,
type ElectricDependencies,
} from './electric.js';
import {
registerHydrogenDailyRoute,
type HydrogenDailyDependencies,
} from './hydrogen-daily.js';
import {
registerHydrogenOverviewRoute,
type HydrogenOverviewDependencies,
} from './hydrogen-overview.js';
interface QueryCall {
sql: string;
params: unknown;
}
interface CacheCall {
key: string;
force: boolean | undefined;
}
function createQueryMock(resultSets: unknown[][], calls: QueryCall[]) {
return async (sql: string, params?: unknown) => {
calls.push({ sql, params });
return [resultSets.shift() ?? [], []];
};
}
function createCachedMock(calls: CacheCall[]) {
return async <T>(
key: string,
loader: () => Promise<T>,
opts: { force?: boolean } = {},
): Promise<T> => {
calls.push({ key, force: opts.force });
return loader();
};
}
function sqlHash(sql: string): string {
return createHash('sha256').update(sql).digest('hex').slice(0, 12);
}
test('能源路由继续按原顺序注册四个 GET 接口', () => {
assert.deepEqual(
app.routes.filter(route => route.method === 'GET').map(route => route.path),
[
'/hydrogen/overview',
'/hydrogen/daily',
'/electric/overview',
'/electric/monthly',
],
);
});
test('四个处理器继续使用原缓存语义、数据库、SQL 与参数', async () => {
const hydrogenCalls: QueryCall[] = [];
const electricCalls: QueryCall[] = [];
const cacheCalls: CacheCall[] = [];
const cached = createCachedMock(cacheCalls);
const hydrogenQuery = createQueryMock([
[{ y: 2025 }],
[{}],
[],
[],
[],
[],
[],
[
{ d: '2025-01-02', stationId: 7, stationName: '测试站', kg: 10, pricePerKg: 35 },
],
], hydrogenCalls);
const electricQuery = createQueryMock([
[{ totalKwh: 20, totalFee: 30, monthKwh: 15, monthFee: 25, todayKwh: 10, todayFee: 12 }],
[],
[{ date: '2025-01-02', kwh: 10, fee: 12 }],
[{ kwh: 5 }],
[{ date: '2025-01-02', kwh: 10, fee: 12 }],
], electricCalls);
const hydrogenOverviewApp = new Hono();
registerHydrogenOverviewRoute(hydrogenOverviewApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenOverviewDependencies);
const overviewResponse = await hydrogenOverviewApp.request(
'/hydrogen/overview?year=2025&force=1',
);
assert.equal(overviewResponse.status, 200);
const hydrogenDailyApp = new Hono();
registerHydrogenDailyRoute(hydrogenDailyApp, {
hydrogenPool: { query: hydrogenQuery },
cached,
} as unknown as HydrogenDailyDependencies);
const dailyResponse = await hydrogenDailyApp.request(
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
);
assert.equal(dailyResponse.status, 200);
const electricApp = new Hono();
const electricDependencies = {
pool: { query: electricQuery },
cached,
} as unknown as ElectricDependencies;
registerElectricOverviewRoute(electricApp, electricDependencies);
registerElectricMonthlyRoute(electricApp, electricDependencies);
const electricOverviewResponse = await electricApp.request('/electric/overview?force=1');
assert.equal(electricOverviewResponse.status, 200);
const electricMonthlyResponse = await electricApp.request(
'/electric/monthly?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
);
assert.equal(electricMonthlyResponse.status, 200);
assert.deepEqual(cacheCalls, [
{ key: 'hydrogen/overview?year=2025', force: true },
{ key: 'hydrogen/daily?start=2025-01-01&end=2025-01-02&customer=external', force: true },
{ key: 'electric/overview', force: true },
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[
'95f6d99b61c9',
'63a63c5f78bb',
'1367eddb3b7f',
'83a52a7f9336',
'f28fa4a3cb08',
'dac9e86fc527',
'a71da8f05765',
'b562668859ce',
'226fd8f950a1',
'6e4031b10926',
'4fc77463294c',
'1886d54939d8',
'09fc714ec4b2',
],
);
assert.deepEqual(hydrogenCalls.map(call => call.params), [
['2024-01-01'],
[2025, 2025, 2025, 2025, 2025, 2025, 2025, 0, 0, 0, 0, 0, 0, 0, 0, '2024-01-01'],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2024-01-01', 2025],
['2025-01-01', '2025-01-02'],
]);
assert.deepEqual(electricCalls.map(call => call.params), [
undefined,
undefined,
undefined,
undefined,
['2025-01-01', '2025-01-02'],
]);
});
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
haversineKm,
hydrogenMetricValue,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenStationRecord,
} from './hydrogen-heatmap-model.js';
function station(overrides: Partial<HydrogenStationRecord> = {}): HydrogenStationRecord {
return {
station_id: '1',
station_name: '测试加氢站',
address: '测试路 1 号',
longitude: '120',
latitude: '30',
kg: '10.5',
refuel_count: '3',
vehicle_count: '2',
first_refuel: '2026-07-01 08:00:00',
last_refuel: '2026-07-02 18:00:00',
...overrides,
};
}
test('加氢热力图查询继续使用固定默认日期、去除搜索空白并限制枚举值', () => {
assert.deepEqual(parseHydrogenHeatmapQuery({}), {
startDate: '2026-01-01',
endDate: '2026-07-13',
query: '',
payer: 'all',
metric: 'kg',
});
assert.deepEqual(parseHydrogenHeatmapQuery({
startDate: '2026-06-01',
endDate: 'bad',
query: ' 嘉兴 ',
payer: 'customer',
metric: 'vehicles',
}), {
startDate: '2026-06-01',
endDate: '2026-07-13',
query: '嘉兴',
payer: 'customer',
metric: 'vehicles',
});
assert.equal(parseHydrogenHeatmapQuery({ payer: 'bad' }).payer, 'all');
assert.equal(parseHydrogenHeatmapQuery({ metric: 'bad' }).metric, 'kg');
});
test('加氢附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
center: { lng: 0, lat: 0 },
radiusKm: 50,
});
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-10' }).radiusKm, 5);
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '201' }).radiusKm, 200);
assert.equal(parseNearbyQuery({ lng: '120', lat: 'bad' }).center, null);
});
test('加氢指标取值和站点序列化继续保持字段、数值转换及零值回退', () => {
const row = station();
assert.equal(hydrogenMetricValue(row, 'kg'), 10.5);
assert.equal(hydrogenMetricValue(row, 'refuels'), 3);
assert.equal(hydrogenMetricValue(row, 'vehicles'), 2);
assert.equal(hydrogenMetricValue(station({ kg: 'bad' }), 'kg'), 0);
assert.deepEqual(serializeHydrogenStation(row, 'refuels'), {
stationId: '1',
stationName: '测试加氢站',
address: '测试路 1 号',
lng: 120,
lat: 30,
kg: 10.5,
refuelCount: 3,
vehicleCount: 2,
firstRefuel: '2026-07-01 08:00:00',
lastRefuel: '2026-07-02 18:00:00',
metricValue: 3,
});
});
test('站点排名和热力点继续按所选指标降序并保留同值输入顺序', () => {
const stations = [
station({ station_id: 'A', kg: 10, longitude: 120, latitude: 30 }),
station({ station_id: 'B', kg: 20, longitude: 121, latitude: 31 }),
station({ station_id: 'C', kg: 20, longitude: 122, latitude: 32 }),
];
const ranked = rankHydrogenStations(stations, 'kg');
assert.deepEqual(ranked.map((row) => row.station_id), ['B', 'C', 'A']);
assert.deepEqual(buildHydrogenPoints(ranked, 'kg'), {
points: [
{ lng: 121, lat: 31, count: 20 },
{ lng: 122, lat: 32, count: 20 },
{ lng: 120, lat: 30, count: 10 },
],
max: 20,
});
assert.deepEqual(buildHydrogenPoints([], 'kg'), { points: [], max: 1 });
});
test('加氢附近筛选继续使用 Haversine 距离并包含边界内站点', () => {
const stations = [
station({ station_id: 'near', longitude: 120.01 }),
station({ station_id: 'far', longitude: 121 }),
];
assert.equal(haversineKm(120, 30, 120, 30), 0);
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
assert.deepEqual(
filterHydrogenStationsByRadius(stations, { lng: 120, lat: 30 }, 5)
.map((row) => row.station_id),
['near'],
);
});
+147
View File
@@ -0,0 +1,147 @@
export type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
export type HydrogenPayer = 'all' | 'lingniu' | 'customer';
export type HydrogenStationRecord = {
station_id: string;
station_name: string;
address: string;
longitude: string | number;
latitude: string | number;
kg: string | number;
refuel_count: string | number;
vehicle_count: string | number;
first_refuel: string;
last_refuel: string;
};
type HydrogenHeatmapQueryInput = {
startDate?: string;
endDate?: string;
query?: string;
payer?: string;
metric?: string;
};
type NearbyQueryInput = {
lng?: string;
lat?: string;
radiusKm?: string;
};
export const HYDROGEN_HEATMAP_DEFAULT_START = '2026-01-01';
export const HYDROGEN_HEATMAP_DEFAULT_END = '2026-07-13';
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
if (value === 'refuels' || value === 'vehicles') return value;
return 'kg';
}
function parsePayer(value: string | undefined): HydrogenPayer {
if (value === 'lingniu' || value === 'customer') return value;
return 'all';
}
export function parseHydrogenHeatmapQuery(input: HydrogenHeatmapQueryInput) {
return {
startDate: isDate(input.startDate) ? input.startDate : HYDROGEN_HEATMAP_DEFAULT_START,
endDate: isDate(input.endDate) ? input.endDate : HYDROGEN_HEATMAP_DEFAULT_END,
query: (input.query || '').trim(),
payer: parsePayer(input.payer),
metric: parseMetric(input.metric),
};
}
export function parseNearbyQuery(input: NearbyQueryInput) {
const longitude = Number(input.lng);
const latitude = Number(input.lat);
return {
center: Number.isFinite(longitude) && Number.isFinite(latitude)
? { lng: longitude, lat: latitude }
: null,
radiusKm: Math.min(200, Math.max(5, Number(input.radiusKm) || 50)),
};
}
export function hydrogenMetricValue(
row: HydrogenStationRecord,
metric: HydrogenHeatmapMetric,
): number {
if (metric === 'refuels') return Number(row.refuel_count) || 0;
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
return Number(row.kg) || 0;
}
export function serializeHydrogenStation(
row: HydrogenStationRecord,
metric: HydrogenHeatmapMetric,
) {
return {
stationId: row.station_id,
stationName: row.station_name,
address: row.address,
lng: Number(row.longitude),
lat: Number(row.latitude),
kg: Number(row.kg) || 0,
refuelCount: Number(row.refuel_count) || 0,
vehicleCount: Number(row.vehicle_count) || 0,
firstRefuel: row.first_refuel,
lastRefuel: row.last_refuel,
metricValue: hydrogenMetricValue(row, metric),
};
}
export function rankHydrogenStations(
stations: HydrogenStationRecord[],
metric: HydrogenHeatmapMetric,
): HydrogenStationRecord[] {
return [...stations].sort((left, right) => (
hydrogenMetricValue(right, metric) - hydrogenMetricValue(left, metric)
));
}
export function buildHydrogenPoints(
stations: HydrogenStationRecord[],
metric: HydrogenHeatmapMetric,
) {
const points = stations.map((row) => ({
lng: Number(row.longitude),
lat: Number(row.latitude),
count: hydrogenMetricValue(row, metric),
}));
return {
points,
max: Math.max(1, ...points.map((point) => point.count)),
};
}
export function haversineKm(
leftLng: number,
leftLat: number,
rightLng: number,
rightLat: number,
): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function filterHydrogenStationsByRadius(
stations: HydrogenStationRecord[],
center: { lng: number; lat: number },
radiusKm: number,
): HydrogenStationRecord[] {
return stations.filter((row) => haversineKm(
center.lng,
center.lat,
Number(row.longitude),
Number(row.latitude),
) <= radiusKm);
}
+50 -98
View File
@@ -3,46 +3,28 @@ import type { RowDataPacket } from 'mysql2';
import pool from '../db.js';
import type { AuthUser } from '../auth/types.js';
import { canAccessEnergy } from '../auth/types.js';
import {
buildHydrogenPoints,
filterHydrogenStationsByRadius,
HYDROGEN_HEATMAP_DEFAULT_END,
HYDROGEN_HEATMAP_DEFAULT_START,
parseHydrogenHeatmapQuery,
parseNearbyQuery,
rankHydrogenStations,
serializeHydrogenStation,
type HydrogenPayer,
type HydrogenStationRecord,
} from './hydrogen-heatmap-model.js';
type HydrogenHeatmapMetric = 'kg' | 'refuels' | 'vehicles';
type Payer = 'all' | 'lingniu' | 'customer';
type StationRow = RowDataPacket & HydrogenStationRecord;
type StationRow = RowDataPacket & {
station_id: string;
station_name: string;
address: string;
longitude: string | number;
latitude: string | number;
kg: string | number;
refuel_count: string | number;
vehicle_count: string | number;
first_refuel: string;
last_refuel: string;
};
const DEFAULT_START = '2026-01-01';
const DEFAULT_END = '2026-07-13';
const VALID_COORDINATE = `
o.longitude BETWEEN 73.5 AND 135.1
AND o.latitude BETWEEN 18 AND 53.6
AND COALESCE(o.province, '') NOT LIKE '%西藏%'
`;
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function parseMetric(value: string | undefined): HydrogenHeatmapMetric {
if (value === 'refuels' || value === 'vehicles') return value;
return 'kg';
}
function parsePayer(value: string | undefined): Payer {
if (value === 'lingniu' || value === 'customer') return value;
return 'all';
}
function buildWhere(startDate: string, endDate: string, query: string, payer: Payer) {
function buildWhere(startDate: string, endDate: string, query: string, payer: HydrogenPayer) {
const clauses = [
`b.del_flag = '0'`,
`DATE(b.refuel_time) BETWEEN ? AND ?`,
@@ -62,7 +44,12 @@ function buildWhere(startDate: string, endDate: string, query: string, payer: Pa
return { sql: clauses.join(' AND '), params };
}
async function loadStations(startDate: string, endDate: string, query: string, payer: Payer): Promise<StationRow[]> {
async function loadStations(
startDate: string,
endDate: string,
query: string,
payer: HydrogenPayer,
): Promise<StationRow[]> {
const where = buildWhere(startDate, endDate, query, payer);
const [rows] = await pool.execute<StationRow[]>(`
SELECT
@@ -91,38 +78,6 @@ async function loadStations(startDate: string, endDate: string, query: string, p
return rows;
}
function metricValue(row: StationRow, metric: HydrogenHeatmapMetric): number {
if (metric === 'refuels') return Number(row.refuel_count) || 0;
if (metric === 'vehicles') return Number(row.vehicle_count) || 0;
return Number(row.kg) || 0;
}
function serializeStation(row: StationRow, metric: HydrogenHeatmapMetric) {
return {
stationId: row.station_id,
stationName: row.station_name,
address: row.address,
lng: Number(row.longitude),
lat: Number(row.latitude),
kg: Number(row.kg) || 0,
refuelCount: Number(row.refuel_count) || 0,
vehicleCount: Number(row.vehicle_count) || 0,
firstRefuel: row.first_refuel,
lastRefuel: row.last_refuel,
metricValue: metricValue(row, metric),
};
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.use('*', async (c, next) => {
@@ -173,8 +128,8 @@ router.get('/meta', async (c) => {
const totalRefuelCount = Number(profile.total_refuel_count) || 0;
const eligibleRefuelCount = Number(profile.eligible_refuel_count) || 0;
return c.json({
startDate: profile.start_date || DEFAULT_START,
endDate: profile.end_date || DEFAULT_END,
startDate: profile.start_date || HYDROGEN_HEATMAP_DEFAULT_START,
endDate: profile.end_date || HYDROGEN_HEATMAP_DEFAULT_END,
totalRefuelCount,
eligibleRefuelCount,
excludedRefuelCount: Math.max(0, totalRefuelCount - eligibleRefuelCount),
@@ -192,19 +147,17 @@ router.get('/meta', async (c) => {
});
router.get('/points', async (c) => {
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const stations = await loadStations(startDate, endDate, query, payer);
const ranked = [...stations].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
const points = ranked.map((row) => ({
lng: Number(row.longitude),
lat: Number(row.latitude),
count: metricValue(row, metric),
}));
const ranked = rankHydrogenStations(stations, metric);
const { points, max } = buildHydrogenPoints(ranked, metric);
const where = buildWhere(startDate, endDate, query, payer);
const [summaryRows] = await pool.execute<RowDataPacket[]>(`
SELECT
@@ -230,28 +183,27 @@ router.get('/points', async (c) => {
stationCount: Number(summary.station_count) || 0,
dayCount: Number(summary.day_count) || 0,
points,
max: Math.max(1, ...points.map((point) => point.count)),
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
max,
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return c.json({ error: '经纬度参数无效' }, 400);
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : DEFAULT_START;
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : DEFAULT_END;
const query = (c.req.query('query') || '').trim();
const payer = parsePayer(c.req.query('payer'));
const metric = parseMetric(c.req.query('metric'));
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) return c.json({ error: '经纬度参数无效' }, 400);
const { startDate, endDate, query, payer, metric } = parseHydrogenHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
payer: c.req.query('payer'),
metric: c.req.query('metric'),
});
const stations = await loadStations(startDate, endDate, query, payer);
const nearby = stations.filter((row) => haversineKm(
longitude,
latitude,
Number(row.longitude),
Number(row.latitude),
) <= radiusKm);
const nearby = filterHydrogenStationsByRadius(stations, center, radiusKm);
const nearbyIds = nearby.map((row) => row.station_id);
let nearbySummary = { kg: 0, refuelCount: 0, vehicleCount: 0, stationCount: 0 };
if (nearbyIds.length) {
@@ -275,15 +227,15 @@ router.get('/nearby', async (c) => {
stationCount: Number(rows[0]?.station_count) || 0,
};
}
const ranked = [...nearby].sort((left, right) => metricValue(right, metric) - metricValue(left, metric));
const ranked = rankHydrogenStations(nearby, metric);
return c.json({
center: { lng: longitude, lat: latitude },
center,
radiusKm,
kg: nearbySummary.kg,
refuelCount: nearbySummary.refuelCount,
vehicleCount: nearbySummary.vehicleCount,
stationCount: nearbySummary.stationCount,
topStations: ranked.slice(0, 10).map((row) => serializeStation(row, metric)),
topStations: ranked.slice(0, 10).map((row) => serializeHydrogenStation(row, metric)),
});
});
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMonitoringFilters,
buildPlateTargetNamesMap,
buildTargetPlatesMap,
dailyMileageMap,
mergeMonitoringVehicles,
mileageDatesBetween,
previousMileageDate,
toMileageRows,
type MileageRow,
} from './cache-model.js';
import type { CachedVehicle, VehicleInfoRow } from './types.js';
const vehicle = (overrides: Partial<CachedVehicle> = {}): CachedVehicle => ({
plate: '粤A10001', vin: '', dailyKm: 0, totalKm: null, source: 'NONE',
sourceProtocol: null, sourceCategory: null, dataTime: null, calculatedAt: null,
updatedAt: null, isOnline: false, isDataSynced: false, customer: null,
department: null, manager: null, managerId: null, rentStatus: null, entity: null,
project: null, region: null, inventoryLocation: null, brand: null, targetNames: [],
yesterdayKm: 0, ...overrides,
});
test('监控筛选继续按业务部门、区域和车牌前缀口径生成', () => {
const filters = buildMonitoringFilters([
vehicle({ plate: '粤A1', department: '业务三部', region: '华南区域', brand: '现代' }),
vehicle({ plate: '浙B1', department: '业务一部', region: '华东区域', brand: '福田' }),
vehicle({ plate: '粤A2', department: '业务二部', region: '其他区域', brand: '现代' }),
], ['190台冷链']);
assert.deepEqual(filters.departments, ['业务一部', '业务二部', '业务三部']);
assert.deepEqual(filters.regions, ['华东区域', '华南区域', '其他区域']);
assert.deepEqual(filters.platePrefixes, [{ prefix: '粤', count: 2 }, { prefix: '浙', count: 1 }]);
assert.deepEqual(filters.brands, ['现代', '福田']);
assert.deepEqual(filters.targetNames, ['190台冷链']);
});
test('车辆合并继续取同车牌最大日里程并保留空累计里程', () => {
const info: VehicleInfoRow = {
plate: '粤A10001', vin: 'info-vin', customer: '客户A', department: '业务一部',
manager: null, manager_id: null, rent_status: '租赁', entity: null, project: null,
inventory_location: null, brand_label: '现代',
};
const rows: MileageRow[] = [
{ plate: '粤A10001', vin: '', daily_km: '8', total_km: null, source: 'ONEOS_API', source_protocol: 'GB32960', data_time: 't1', calculated_at: null, updated_at: null },
{ plate: '粤A10001', vin: 'api-vin', daily_km: '12', total_km: null, source: 'ONEOS_API', source_protocol: 'MQTT', data_time: 't2', calculated_at: 'c2', updated_at: 'u2' },
];
const [merged] = mergeMonitoringVehicles(
rows,
new Map([[info.plate, info]]),
new Map([[info.plate, 7]]),
new Map([[info.plate, ['目标A']]]),
{ [info.plate]: '华南区域' },
);
assert.equal(merged.dailyKm, 12);
assert.equal(merged.totalKm, null);
assert.equal(merged.vin, 'api-vin');
assert.equal(merged.sourceProtocol, 'MQTT');
assert.equal(merged.sourceCategory, 'INSTRUMENT');
assert.equal(merged.region, '华南区域');
assert.equal(merged.yesterdayKm, 7);
assert.deepEqual(merged.targetNames, ['目标A']);
});
test('OneOS 行、昨日里程和考核映射保持原始规则', () => {
const normalized = toMileageRows([{ plateNumber: '粤A1', vin: 'v1', date: '2026-08-12', dailyMileageKm: null, totalMileageKm: null, status: 'NO_DATA', dataTime: null, calculatedAt: null, updatedAt: null, sourceProtocol: null }]);
assert.equal(normalized[0].daily_km, '0');
assert.equal(normalized[0].source, 'NONE');
const yesterday = dailyMileageMap([
{ plateNumber: '粤A1', vin: '', date: '2026-08-11', dailyMileageKm: 5, totalMileageKm: null, status: 'NORMAL', dataTime: null, calculatedAt: null, updatedAt: null, sourceProtocol: null },
{ plateNumber: '粤A1', vin: '', date: '2026-08-11', dailyMileageKm: 8, totalMileageKm: null, status: 'NORMAL', dataTime: null, calculatedAt: null, updatedAt: null, sourceProtocol: null },
]);
assert.equal(yesterday.get('粤A1'), 8);
const targets = [
{ id: 1, target_name: '目标A', plate_number: '粤A1' },
{ id: 2, target_name: '目标B', plate_number: '粤A1' },
];
assert.deepEqual(Array.from(buildTargetPlatesMap(targets).get('目标A') || []), ['粤A1']);
assert.deepEqual(buildPlateTargetNamesMap(targets).get('粤A1'), ['目标A', '目标B']);
});
test('里程自然日工具继续包含区间首尾', () => {
assert.equal(previousMileageDate('2026-03-01'), '2026-02-28');
assert.deepEqual(mileageDatesBetween('2026-08-10', '2026-08-12'), [
'2026-08-10', '2026-08-11', '2026-08-12',
]);
});
+220
View File
@@ -0,0 +1,220 @@
import { sourceCategoryFromProtocol, type OneOsProtocol } from './source-policy.js';
import type { OneOsDailyMileage } from './oneos-model.js';
import type {
CachedVehicle,
MonitoringFilters,
PlatePrefix,
VehicleInfoRow,
} from './types.js';
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
const DEPARTMENT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
export interface MileageRow {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
export interface DailyMileageRow {
plate: string;
vin: string | null;
date: string;
daily_km: string | number | null;
source: string | null;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
export interface TargetRow {
id: number;
target_name: string;
plate_number: string;
}
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPARTMENT_ORDER.findIndex(value => a.includes(value));
const bi = DEPARTMENT_ORDER.findIndex(value => b.includes(value));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
export function buildMonitoringFilters(
vehicles: CachedVehicle[],
targetNames: string[],
): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(vehicle => vehicle.department)
.filter((value): value is string => value !== null))),
);
const customers = Array.from(new Set(vehicles.map(vehicle => vehicle.customer)
.filter((value): value is string => value !== null)));
const plates = vehicles.map(vehicle => vehicle.plate);
const projects = Array.from(new Set(vehicles.map(vehicle => vehicle.project)
.filter((value): value is string => value !== null)));
const entities = Array.from(new Set(vehicles.map(vehicle => vehicle.entity)
.filter((value): value is string => value !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(vehicle => vehicle.rentStatus)
.filter((value): value is string => value !== null)));
const brands = Array.from(new Set(vehicles.map(vehicle => vehicle.brand)
.filter((value): value is string => value !== null))).sort();
const prefixCount = new Map<string, number>();
for (const vehicle of vehicles) {
const prefix = vehicle.plate.charAt(0);
prefixCount.set(prefix, (prefixCount.get(prefix) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
const regionSet = new Set(vehicles.map(vehicle => vehicle.region)
.filter((value): value is string => value !== null));
const regions = Array.from(regionSet).sort((a, b) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
return {
departments,
customers,
plates,
projects,
entities,
rentStatuses,
platePrefixes,
targetNames,
regions,
brands,
};
}
export function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
return rows.map(row => ({
plate: row.plateNumber,
vin: row.vin,
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
}));
}
export function previousMileageDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
const value = new Date(Date.UTC(year, month - 1, day));
value.setUTCDate(value.getUTCDate() - 1);
return value.toISOString().slice(0, 10);
}
export function dailyMileageMap(rows: OneOsDailyMileage[]): Map<string, number> {
const map = new Map<string, number>();
for (const row of rows) {
if (row.status !== 'NORMAL') continue;
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
const existing = map.get(row.plateNumber) || 0;
if (km > existing) map.set(row.plateNumber, km);
}
return map;
}
export function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
const result = new Map<string, Set<string>>();
for (const row of targetRows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
}
export function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const result = new Map<string, string[]>();
for (const row of targetRows) {
const names = result.get(row.plate_number) || [];
names.push(row.target_name);
result.set(row.plate_number, names);
}
return result;
}
export function mergeMonitoringVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
regionByPlate: Record<string, string>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(infoMap.values()).map(info => {
const mileage = mileageMap.get(info.plate);
const dailyKm = Number(mileage?.daily_km) || 0;
const source = mileage?.source || 'NONE';
return {
plate: info.plate,
vin: mileage?.vin || info.vin || '',
dailyKm,
// Cumulative mileage is never backfilled from a different source here.
totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null,
source,
sourceProtocol: mileage?.source_protocol || null,
sourceCategory: sourceCategoryFromProtocol(mileage?.source_protocol || null),
dataTime: mileage?.data_time || null,
calculatedAt: mileage?.calculated_at || null,
updatedAt: mileage?.updated_at || null,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info.customer || null,
department: info.department || null,
manager: info.manager || null,
managerId: info.manager_id || null,
rentStatus: info.rent_status || null,
entity: info.entity || null,
project: info.project || null,
region: regionByPlate[info.plate] || null,
inventoryLocation: info.inventory_location || null,
brand: info.brand_label || null,
targetNames: targetNamesByPlate.get(info.plate) || [],
yesterdayKm: yesterdayMap.get(info.plate) || 0,
};
});
}
export function mileageDatesBetween(start: string, end: string): string[] {
const result: string[] = [];
const [startYear, startMonth, startDay] = start.split('-').map(Number);
const [endYear, endMonth, endDay] = end.split('-').map(Number);
const cursor = new Date(startYear, startMonth - 1, startDay);
const last = new Date(endYear, endMonth - 1, endDay);
cursor.setHours(0, 0, 0, 0);
last.setHours(0, 0, 0, 0);
while (cursor <= last) {
result.push([
cursor.getFullYear(),
String(cursor.getMonth() + 1).padStart(2, '0'),
String(cursor.getDate()).padStart(2, '0'),
].join('-'));
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
+33 -212
View File
@@ -3,111 +3,37 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
import {
buildMonitoringFilters,
buildPlateTargetNamesMap,
buildTargetPlatesMap,
dailyMileageMap,
mergeMonitoringVehicles,
mileageDatesBetween,
previousMileageDate,
toMileageRows,
type DailyMileageRow,
type MileageRow,
type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const regionMap: Record<string, string> = JSON.parse(
readFileSync(join(__dirname, 'region-map.json'), 'utf8')
);
const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域'];
let monitoringCache: MonitoringCache | null = null;
export function getCache(): MonitoringCache | null {
return monitoringCache;
}
const DEPT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
function sortDepartments(departments: string[]): string[] {
return departments.sort((a, b) => {
const ai = DEPT_ORDER.findIndex(d => a.includes(d));
const bi = DEPT_ORDER.findIndex(d => b.includes(d));
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function buildFilters(vehicles: CachedVehicle[], targetNames: string[]): MonitoringFilters {
const departments = sortDepartments(
Array.from(new Set(vehicles.map(v => v.department).filter((d): d is string => d !== null)))
);
const customers = Array.from(new Set(vehicles.map(v => v.customer).filter((c): c is string => c !== null)));
const plates = vehicles.map(v => v.plate);
const projects = Array.from(new Set(vehicles.map(v => v.project).filter((p): p is string => p !== null)));
const entities = Array.from(new Set(vehicles.map(v => v.entity).filter((e): e is string => e !== null)));
const rentStatuses = Array.from(new Set(vehicles.map(v => v.rentStatus).filter((r): r is string => r !== null)));
const brands = Array.from(new Set(vehicles.map(v => v.brand).filter((b): b is string => b !== null))).sort();
const prefixCount = new Map<string, number>();
for (const v of vehicles) {
const p = v.plate.charAt(0);
prefixCount.set(p, (prefixCount.get(p) || 0) + 1);
}
const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries())
.map(([prefix, count]) => ({ prefix, count }))
.sort((a, b) => b.count - a.count);
const regionSet = new Set(vehicles.map(v => v.region).filter((r): r is string => r !== null));
const regions = Array.from(regionSet).sort((a, b) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands };
}
interface MileageRow {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
interface DailyMileageRow {
plate: string;
vin: string | null;
date: string;
daily_km: string | number | null;
source: string | null;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}
function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
return rows.map(row => ({
plate: row.plateNumber,
vin: row.vin,
daily_km: String(row.dailyMileageKm ?? 0),
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
}));
}
function previousDate(date: string): string {
const [year, month, day] = date.split('-').map(Number);
const value = new Date(Date.UTC(year, month - 1, day));
value.setUTCDate(value.getUTCDate() - 1);
return value.toISOString().slice(0, 10);
}
function shanghaiDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
@@ -117,17 +43,6 @@ function shanghaiDate(): string {
}).format(new Date());
}
function dailyMileageMap(rows: OneOsDailyMileage[]): Map<string, number> {
const map = new Map<string, number>();
for (const row of rows) {
if (row.status !== 'NORMAL') continue;
const km = Math.max(0, Number(row.dailyMileageKm) || 0);
const existing = map.get(row.plateNumber) || 0;
if (km > existing) map.set(row.plateNumber, km);
}
return map;
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
@@ -135,12 +50,6 @@ export interface RangeMileageResult {
end: string;
}
interface TargetRow {
id: number;
target_name: string;
plate_number: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
@@ -150,77 +59,6 @@ async function fetchTargetRows(): Promise<TargetRow[]> {
).then(([rows]) => rows as TargetRow[]);
}
function buildTargetPlatesMap(targetRows: TargetRow[]): Map<string, Set<string>> {
const targetPlatesMap = new Map<string, Set<string>>();
for (const r of targetRows) {
const set = targetPlatesMap.get(r.target_name) || new Set();
set.add(r.plate_number);
targetPlatesMap.set(r.target_name, set);
}
return targetPlatesMap;
}
function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const map = new Map<string, string[]>();
for (const r of targetRows) {
const list = map.get(r.plate_number) || [];
list.push(r.target_name);
map.set(r.plate_number, list);
}
return map;
}
function mergeVehicles(
mileageRows: MileageRow[],
infoMap: Map<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
for (const row of mileageRows) {
const existing = mileageMap.get(row.plate);
if (!existing || Number(row.daily_km) > Number(existing.daily_km)) {
mileageMap.set(row.plate, row);
}
}
return Array.from(infoMap.values()).map(info => {
const m = mileageMap.get(info.plate);
const plate = info.plate;
const dailyKm = Number(m?.daily_km) || 0;
const source = m?.source || 'NONE';
const gpsTotal = m?.total_km != null ? Number(m.total_km) : null;
return {
plate,
vin: m?.vin || info.vin || '',
dailyKm,
// The OneOS daily mileage API does not currently return cumulative mileage.
// Never backfill it from another mileage source outside the assessment page.
totalKm: gpsTotal,
source,
sourceProtocol: m?.source_protocol || null,
sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null),
dataTime: m?.data_time || null,
calculatedAt: m?.calculated_at || null,
updatedAt: m?.updated_at || null,
isOnline: source !== 'NONE' && dailyKm > 0,
isDataSynced: source !== 'NONE',
customer: info.customer || null,
department: info.department || null,
manager: info.manager || null,
managerId: info.manager_id || null,
rentStatus: info.rent_status || null,
entity: info.entity || null,
project: info.project || null,
region: regionMap[plate] || null,
inventoryLocation: info.inventory_location || null,
brand: info.brand_label || null,
targetNames: targetNamesByPlate.get(plate) || [],
yesterdayKm: yesterdayMap.get(plate) || 0,
};
});
}
export async function refreshMonitoringCache(): Promise<void> {
try {
console.log('[mileage] refreshing monitoring cache...');
@@ -229,7 +67,7 @@ export async function refreshMonitoringCache(): Promise<void> {
const date = shanghaiDate();
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(date),
fetchOneOsDailyMileage(previousDate(date)),
fetchOneOsDailyMileage(previousMileageDate(date)),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -241,14 +79,20 @@ export async function refreshMonitoringCache(): Promise<void> {
const targetNamesByPlate = buildPlateTargetNamesMap(targetRows);
const targetNames = Array.from(targetPlatesMap.keys());
const vehicles = mergeVehicles(mileageRows, infoMap, yesterdayMap, targetNamesByPlate);
const vehicles = mergeMonitoringVehicles(
mileageRows,
infoMap,
yesterdayMap,
targetNamesByPlate,
regionMap,
);
const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0));
const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0);
monitoringCache = {
vehicles,
stats: { totalToday, totalAll, vehicleCount: vehicles.length },
filters: buildFilters(vehicles, targetNames),
filters: buildMonitoringFilters(vehicles, targetNames),
targetPlatesMap,
updatedAt: new Date().toISOString(),
};
@@ -265,7 +109,7 @@ export async function queryDateMileage(
): Promise<CachedVehicle[]> {
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
fetchOneOsDailyMileage(previousMileageDate(dateStr), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -273,29 +117,15 @@ export async function queryDateMileage(
const mileageRows = toMileageRows(apiRows);
const yesterdayMap = dailyMileageMap(yesterdayRows);
return mergeVehicles(
return mergeMonitoringVehicles(
mileageRows,
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
);
}
function datesBetween(start: string, end: string): string[] {
const result: string[] = [];
const [sy, sm, sd] = start.split('-').map(Number);
const [ey, em, ed] = end.split('-').map(Number);
const cursor = new Date(sy, sm - 1, sd);
const last = new Date(ey, em - 1, ed);
cursor.setHours(0, 0, 0, 0);
last.setHours(0, 0, 0, 0);
while (cursor <= last) {
result.push(`${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`);
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
export async function queryRangeMileage(
startDate: string,
endDate: string,
@@ -318,11 +148,11 @@ export async function queryRangeMileage(
};
}
const days = datesBetween(startDate, endDate);
const days = mileageDatesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority),
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority),
fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority),
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
@@ -346,17 +176,7 @@ export async function queryRangeMileage(
const perVehicleDaily = new Map<string, Record<string, number>>();
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
const perVehicleSum = new Map<string, {
plate: string;
vin: string;
daily_km: string;
total_km: string | null;
source: string;
source_protocol: OneOsProtocol | null;
data_time: string | null;
calculated_at: string | null;
updated_at: string | null;
}>();
const perVehicleSum = new Map<string, MileageRow>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
@@ -420,11 +240,12 @@ export async function queryRangeMileage(
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeVehicles(
const vehicles = mergeMonitoringVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
@@ -452,5 +273,5 @@ export async function queryRangeMileage(
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
return buildFilters(vehicles, monitoringCache?.filters.targetNames || []);
return buildMonitoringFilters(vehicles, monitoringCache?.filters.targetNames || []);
}
@@ -2,7 +2,9 @@ import { archiveDailyMileageReport, previousShanghaiDate } from './daily-report-
import { hasDailyReportSnapshot } from './daily-report-store.js';
const FIRST_CHECK_DELAY_MS = 15 * 1000;
const SUMMARY_MINUTE_OF_DAY = 30;
// OneOS mileage can arrive after midnight. Archive the prior natural day only
// after the agreed 06:30 Shanghai-time collection window has passed.
const SUMMARY_MINUTE_OF_DAY = 6 * 60 + 30;
const SUMMARY_WINDOW_MINUTES = 5;
let archiving = false;
@@ -292,11 +292,14 @@ export async function getOrGenerateDailyMileageReport(reportDate: string): Promi
return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE');
}
export async function archiveDailyMileageReport(reportDate: string): Promise<DailyMileageReport> {
export async function archiveDailyMileageReport(
reportDate: string,
options: { replace?: boolean } = {},
): Promise<DailyMileageReport> {
const existing = await getDailyReportSnapshot(reportDate);
if (existing) return existing;
if (existing && !options.replace) return existing;
const report = await generateDailyMileageReport(reportDate, 'ARCHIVED', 'AUTO');
await saveDailyReportSnapshot(report, 'AUTO');
await saveDailyReportSnapshot(report, 'AUTO', { replace: options.replace });
return report;
}
+15 -4
View File
@@ -16,9 +16,20 @@ app.route('/trend', trendRouter);
app.route('/vehicle', vehicleRecentRouter);
app.route('/reports', dailyReportRouter);
// 启动时立即刷新缓存,之后每分钟刷新
refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
startDailyReportScheduler();
let backgroundJobsStarted = false;
/**
* 启动里程后台任务。路由装配阶段不自动调用,避免导入模块时产生副作用。
* 幂等保护用于防止开发热更新或重复引导时创建多组定时器。
*/
export function startMileageBackgroundJobs(): void {
if (backgroundJobsStarted) return;
backgroundJobsStarted = true;
// 保留原有行为:启动时立即刷新一次,之后每分钟刷新。
void refreshMonitoringCache();
setInterval(refreshMonitoringCache, 60 * 1000);
startDailyReportScheduler();
}
export default app;
+20 -118
View File
@@ -1,9 +1,19 @@
import dotenv from 'dotenv';
import {
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
normalizeOneOsProtocol,
type OneOsProtocol,
} from './source-policy.js';
import {
assertMileageDate,
groupContiguousMileageDates,
normalizeOneOsRangeRows,
normalizeOneOsRows,
normalizeSelectedPlates,
oneOsRangeRequestKey,
type OneOsDailyMileage,
} from './oneos-model.js';
export type { OneOsDailyMileage } from './oneos-model.js';
dotenv.config();
@@ -15,19 +25,6 @@ const HISTORICAL_TTL_MS = 6 * 60 * 60 * 1000;
const MAX_CACHE_DATES = 400;
const RANGE_PAGE_SIZE = 5000;
export interface OneOsDailyMileage {
vin: string;
plateNumber: string;
date: string;
dailyMileageKm: number | null;
totalMileageKm: number | null;
status: 'NORMAL' | 'NO_DATA';
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
sourceProtocol: OneOsProtocol | null;
}
interface OneOsMileageResponse {
code?: string;
message?: string;
@@ -78,80 +75,6 @@ function shanghaiDate(): string {
}).format(new Date());
}
function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage[] {
if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field');
const best = new Map<string, OneOsDailyMileage>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const vin = typeof row.vin === 'string' ? row.vin.trim() : '';
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
const date = typeof row.date === 'string' ? row.date : requestedDate;
const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA';
const rawKm = row.dailyMileageKm;
const numericKm = rawKm === null || rawKm === undefined ? null : Number(rawKm);
const dailyMileageKm = status === 'NORMAL' && numericKm !== null && Number.isFinite(numericKm)
? Math.max(0, numericKm)
: null;
const rawTotalKm = row.totalMileageKm;
const numericTotalKm = rawTotalKm === null || rawTotalKm === undefined ? null : Number(rawTotalKm);
const totalMileageKm = status === 'NORMAL' && numericTotalKm !== null && Number.isFinite(numericTotalKm)
? Math.max(0, numericTotalKm)
: null;
const stringField = (...names: string[]): string | null => {
for (const name of names) {
const field = row[name];
if (typeof field === 'string' && field.trim()) return field.trim();
}
return null;
};
// dataTime is the preferred contract. Aliases keep the BI forward-compatible
// while the Open API rolls out the formal field name.
const dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
const calculatedAt = stringField('calculatedAt', 'calculationTime');
const updatedAt = stringField('updatedAt');
const sourceProtocol = normalizeOneOsProtocol(
stringField('sourceProtocol', 'protocol', 'dataSource'),
);
if (!plateNumber || date !== requestedDate) continue;
const normalized: OneOsDailyMileage = {
vin,
plateNumber,
date,
dailyMileageKm,
totalMileageKm,
status,
dataTime,
calculatedAt,
updatedAt,
sourceProtocol,
};
const existing = best.get(plateNumber);
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
best.set(plateNumber, normalized);
}
}
return Array.from(best.values());
}
function normalizeRangeRows(value: unknown, startDate: string, endDate: string): Map<string, OneOsDailyMileage[]> {
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
const rawByDate = new Map<string, unknown[]>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const date = (item as Record<string, unknown>).date;
if (typeof date !== 'string' || date < startDate || date > endDate) continue;
const rows = rawByDate.get(date) || [];
rows.push(item);
rawByDate.set(date, rows);
}
const result = new Map<string, OneOsDailyMileage[]>();
for (const [date, rows] of rawByDate) result.set(date, normalizeRows(rows, date));
return result;
}
async function requestDate(
date: string,
protocolPriority: OneOsProtocol[],
@@ -193,7 +116,7 @@ async function requestDate(
lastError = error;
} else {
if (includeProtocolPriority) protocolPrioritySupported = true;
return normalizeRows(payload.data, date);
return normalizeOneOsRows(payload.data, date);
}
} catch (error) {
lastError = error;
@@ -280,7 +203,7 @@ async function requestRange(
snapshotId = payload.snapshotId || snapshotId;
allRows.push(...payload.data);
const nextCursor = payload.nextCursor || null;
if (!nextCursor) return normalizeRangeRows(allRows, startDate, endDate);
if (!nextCursor) return normalizeOneOsRangeRows(allRows, startDate, endDate);
if (seenCursors.has(nextCursor)) throw new Error('OneOS mileage range API returned a repeated cursor');
seenCursors.add(nextCursor);
cursor = nextCursor;
@@ -302,7 +225,7 @@ export async function fetchOneOsDailyMileage(
plateNumbers?: string[],
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<OneOsDailyMileage[]> {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
assertMileageDate(date);
const policyKey = protocolPriority.join('>');
const cacheKey = `${date}:${policyKey}`;
@@ -338,38 +261,17 @@ export async function fetchOneOsMileageDates(
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
): Promise<Map<string, OneOsDailyMileage[]>> {
const result = new Map<string, OneOsDailyMileage[]>();
const uniqueDates = Array.from(new Set(dates)).sort();
const selectedPlates = Array.from(new Set(
(plateNumbers || []).map(plate => plate.trim()).filter(Boolean),
)).sort();
if (uniqueDates.length === 0) return result;
for (const date of uniqueDates) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
result.set(date, []);
}
const groups: string[][] = [];
for (const date of uniqueDates) {
const current = groups[groups.length - 1];
if (!current) {
groups.push([date]);
continue;
}
const previous = new Date(`${current[current.length - 1]}T00:00:00Z`);
previous.setUTCDate(previous.getUTCDate() + 1);
if (previous.toISOString().slice(0, 10) === date && current.length < 366) current.push(date);
else groups.push([date]);
const selectedPlates = normalizeSelectedPlates(plateNumbers);
if (dates.length === 0) return result;
const groups = groupContiguousMileageDates(dates);
for (const group of groups) {
for (const date of group) result.set(date, []);
}
await Promise.all(groups.map(async group => {
const startDate = group[0];
const endDate = group[group.length - 1];
const key = [
startDate,
endDate,
selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*',
protocolPriority.join('>'),
].join(':');
const key = oneOsRangeRequestKey(startDate, endDate, selectedPlates, protocolPriority);
let pending = rangeInflight.get(key);
if (!pending) {
pending = requestRange(
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertMileageDate,
groupContiguousMileageDates,
normalizeOneOsRangeRows,
normalizeOneOsRows,
normalizeSelectedPlates,
oneOsRangeRequestKey,
} from './oneos-model.js';
test('OneOS 单日响应兼容时间和协议别名并按车牌保留最大里程', () => {
const rows = normalizeOneOsRows([
{
vin: ' vin-1 ',
plateNumber: ' 粤A10001 ',
date: '2026-08-12',
dailyMileageKm: 8,
totalMileageKm: 100,
status: 'NORMAL',
statisticTime: ' 2026-08-12T21:00:00+08:00 ',
calculationTime: '2026-08-12T21:01:00+08:00',
dataSource: 'gb-32960',
},
{
vin: 'vin-2',
plateNumber: '粤A10001',
date: '2026-08-12',
dailyMileageKm: 12,
totalMileageKm: 110,
status: 'NORMAL',
recordTime: '2026-08-12T22:00:00+08:00',
protocol: 'MQTT',
},
{ plateNumber: '粤A10002', date: '2026-08-11', dailyMileageKm: 99, status: 'NORMAL' },
], '2026-08-12');
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
vin: 'vin-2',
plateNumber: '粤A10001',
date: '2026-08-12',
dailyMileageKm: 12,
totalMileageKm: 110,
status: 'NORMAL',
dataTime: '2026-08-12T22:00:00+08:00',
calculatedAt: null,
updatedAt: null,
sourceProtocol: 'MQTT',
});
});
test('NO_DATA 与非法里程继续归一为空值,负数里程继续归零', () => {
const rows = normalizeOneOsRows([
{ plateNumber: '粤A1', status: 'NO_DATA', dailyMileageKm: 5, totalMileageKm: 10 },
{ plateNumber: '粤A2', status: 'NORMAL', dailyMileageKm: -5, totalMileageKm: 'bad' },
], '2026-08-12');
assert.equal(rows[0].dailyMileageKm, null);
assert.equal(rows[0].totalMileageKm, null);
assert.equal(rows[1].dailyMileageKm, 0);
assert.equal(rows[1].totalMileageKm, null);
});
test('区间响应只保留边界内日期并按日期分组', () => {
const rows = normalizeOneOsRangeRows([
{ plateNumber: '粤A1', date: '2026-08-10', status: 'NORMAL', dailyMileageKm: 1 },
{ plateNumber: '粤A2', date: '2026-08-11', status: 'NORMAL', dailyMileageKm: 2 },
{ plateNumber: '粤A3', date: '2026-08-12', status: 'NORMAL', dailyMileageKm: 3 },
], '2026-08-10', '2026-08-11');
assert.deepEqual(Array.from(rows.keys()), ['2026-08-10', '2026-08-11']);
assert.equal(rows.get('2026-08-11')?.[0].plateNumber, '粤A2');
});
test('连续日期分组、车牌规范化和请求键保持稳定', () => {
assert.deepEqual(groupContiguousMileageDates([
'2026-08-12', '2026-08-10', '2026-08-11', '2026-08-11', '2026-08-14',
]), [
['2026-08-10', '2026-08-11', '2026-08-12'],
['2026-08-14'],
]);
const plates = normalizeSelectedPlates([' 粤A2 ', '', '粤A1', '粤A2']);
assert.deepEqual(plates, ['粤A1', '粤A2']);
assert.equal(
oneOsRangeRequestKey('2026-08-10', '2026-08-12', plates, ['GB32960', 'MQTT']),
'2026-08-10:2026-08-12:粤A1\u0000粤A2:GB32960>MQTT',
);
assert.throws(() => assertMileageDate('2026/08/12'), /Invalid mileage date/);
});
+137
View File
@@ -0,0 +1,137 @@
import { normalizeOneOsProtocol, type OneOsProtocol } from './source-policy.js';
export interface OneOsDailyMileage {
vin: string;
plateNumber: string;
date: string;
dailyMileageKm: number | null;
totalMileageKm: number | null;
status: 'NORMAL' | 'NO_DATA';
dataTime: string | null;
calculatedAt: string | null;
updatedAt: string | null;
sourceProtocol: OneOsProtocol | null;
}
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function assertMileageDate(date: string): void {
if (!MILEAGE_DATE_PATTERN.test(date)) throw new Error(`Invalid mileage date: ${date}`);
}
function stringField(row: Record<string, unknown>, ...names: string[]): string | null {
for (const name of names) {
const value = row[name];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
function nonNegativeMileage(value: unknown, status: OneOsDailyMileage['status']): number | null {
if (status !== 'NORMAL' || value === null || value === undefined) return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, numeric) : null;
}
/** Normalize the provider response and retain the highest mileage row per plate. */
export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOsDailyMileage[] {
if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field');
const best = new Map<string, OneOsDailyMileage>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const row = item as Record<string, unknown>;
const vin = typeof row.vin === 'string' ? row.vin.trim() : '';
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
const date = typeof row.date === 'string' ? row.date : requestedDate;
const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA';
// The aliases keep the BI compatible while the Open API formalizes its fields.
const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime');
const calculatedAt = stringField(row, 'calculatedAt', 'calculationTime');
const updatedAt = stringField(row, 'updatedAt');
const sourceProtocol = normalizeOneOsProtocol(
stringField(row, 'sourceProtocol', 'protocol', 'dataSource'),
);
if (!plateNumber || date !== requestedDate) continue;
const normalized: OneOsDailyMileage = {
vin,
plateNumber,
date,
dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status),
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status),
status,
dataTime,
calculatedAt,
updatedAt,
sourceProtocol,
};
const existing = best.get(plateNumber);
if (!existing || (normalized.dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
best.set(plateNumber, normalized);
}
}
return Array.from(best.values());
}
export function normalizeOneOsRangeRows(
value: unknown,
startDate: string,
endDate: string,
): Map<string, OneOsDailyMileage[]> {
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
const rawByDate = new Map<string, unknown[]>();
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const date = (item as Record<string, unknown>).date;
if (typeof date !== 'string' || date < startDate || date > endDate) continue;
const rows = rawByDate.get(date) || [];
rows.push(item);
rawByDate.set(date, rows);
}
const result = new Map<string, OneOsDailyMileage[]>();
for (const [date, rows] of rawByDate) result.set(date, normalizeOneOsRows(rows, date));
return result;
}
export function normalizeSelectedPlates(plateNumbers?: string[]): string[] {
return Array.from(new Set(
(plateNumbers || []).map(plate => plate.trim()).filter(Boolean),
)).sort();
}
export function groupContiguousMileageDates(dates: string[], maxGroupSize = 366): string[][] {
const groups: string[][] = [];
for (const date of Array.from(new Set(dates)).sort()) {
assertMileageDate(date);
const current = groups[groups.length - 1];
if (!current) {
groups.push([date]);
continue;
}
const nextDate = new Date(`${current[current.length - 1]}T00:00:00Z`);
nextDate.setUTCDate(nextDate.getUTCDate() + 1);
if (nextDate.toISOString().slice(0, 10) === date && current.length < maxGroupSize) {
current.push(date);
} else {
groups.push([date]);
}
}
return groups;
}
export function oneOsRangeRequestKey(
startDate: string,
endDate: string,
selectedPlates: string[],
protocolPriority: OneOsProtocol[],
): string {
return [
startDate,
endDate,
selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*',
protocolPriority.join('>'),
].join(':');
}
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
activeNotificationKey,
buildActiveNotificationMap,
buildNotificationUpdate,
hasRequiredNotifyFields,
isNotificationStatus,
rowToNotificationRecord,
} from './notification-model.js';
test('通知数据库行继续转换为前端字段和 ISO 时间', () => {
const record = rowToNotificationRecord({
id: '12', suggestion_id: 's-1', current_plate: '粤A1', candidate_plate: '粤A2',
operator_id: 'u-1', operator_name: '测试员', status: 'executed',
created_at: '2026-08-12T01:00:00.000Z', updated_at: null,
executed_at: '2026-08-12T02:00:00.000Z', notes: '完成', before_mileage: '10.5',
after_mileage: null,
});
assert.deepEqual(record, {
id: 12, suggestionId: 's-1', currentPlate: '粤A1', candidatePlate: '粤A2',
operatorId: 'u-1', operatorName: '测试员', status: 'executed',
createdAt: '2026-08-12T01:00:00.000Z', updatedAt: '',
executedAt: '2026-08-12T02:00:00.000Z', notes: '完成', beforeMileage: 10.5,
afterMileage: null,
});
});
test('有效通知映射继续保留查询结果中同组合的第一条', () => {
const rows = [
{ id: 3, suggestion_id: 's-1', candidate_plate: '粤A2', status: 'executed' as const },
{ id: 2, suggestion_id: 's-1', candidate_plate: '粤A2', status: 'sent' as const },
{ id: 1, suggestion_id: 's-1', candidate_plate: '粤A3', status: 'sent' as const },
];
const map = buildActiveNotificationMap(rows);
assert.equal(activeNotificationKey('s-1', '粤A2'), 's-1::粤A2');
assert.deepEqual(map.get('s-1::粤A2'), { id: 3, status: 'executed' });
assert.deepEqual(map.get('s-1::粤A3'), { id: 1, status: 'sent' });
});
test('通知请求和状态校验继续拒绝空值与未知状态', () => {
assert.equal(hasRequiredNotifyFields({ suggestionId: 's', currentPlate: '粤A1', candidatePlate: '粤A2' }), true);
assert.equal(hasRequiredNotifyFields({ suggestionId: 's', currentPlate: '', candidatePlate: '粤A2' }), false);
assert.equal(isNotificationStatus('cancelled'), true);
assert.equal(isNotificationStatus('pending'), false);
});
test('执行更新继续按原顺序生成状态、执行时间、备注和里程参数', () => {
assert.deepEqual(buildNotificationUpdate({
status: 'executed', notes: '已换车', afterMileage: 123.4,
}, 9), {
fields: ['status = ?', 'executed_at = CURRENT_TIMESTAMP', 'notes = ?', 'after_mileage = ?'],
params: ['executed', '已换车', 123.4, 9],
});
assert.deepEqual(buildNotificationUpdate({ status: 'cancelled' }, 10), {
fields: ['status = ?'],
params: ['cancelled', 10],
});
});
@@ -0,0 +1,97 @@
import type {
NotificationRecord,
NotificationStatus,
NotifyRequest,
UpdateNotificationRequest,
} from './types.js';
export interface NotificationDbRow {
id: unknown;
suggestion_id: string;
current_plate: string;
candidate_plate: string;
operator_id: string | null;
operator_name: string | null;
status: NotificationStatus;
created_at: string | number | Date | null;
updated_at: string | number | Date | null;
executed_at: string | number | Date | null;
notes: string | null;
before_mileage: string | number | null;
after_mileage: string | number | null;
}
export interface ActiveNotificationRow {
id: unknown;
suggestion_id: string;
candidate_plate: string;
status: NotificationStatus;
}
export interface NotificationOperator {
id: string | null;
name: string | null;
}
export function rowToNotificationRecord(row: NotificationDbRow): NotificationRecord {
return {
id: Number(row.id),
suggestionId: row.suggestion_id,
currentPlate: row.current_plate,
candidatePlate: row.candidate_plate,
operatorId: row.operator_id,
operatorName: row.operator_name,
status: row.status,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
notes: row.notes,
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
};
}
export function activeNotificationKey(suggestionId: string, candidatePlate: string): string {
return `${suggestionId}::${candidatePlate}`;
}
/** Rows are already newest-first; retaining the first key preserves latest-record semantics. */
export function buildActiveNotificationMap(
rows: ActiveNotificationRow[],
): Map<string, { id: number; status: NotificationStatus }> {
const result = new Map<string, { id: number; status: NotificationStatus }>();
for (const row of rows) {
const key = activeNotificationKey(row.suggestion_id, row.candidate_plate);
if (!result.has(key)) result.set(key, { id: Number(row.id), status: row.status });
}
return result;
}
export function hasRequiredNotifyFields(
request: Partial<NotifyRequest> | null | undefined,
): request is NotifyRequest {
return Boolean(request?.suggestionId && request.currentPlate && request.candidatePlate);
}
export function isNotificationStatus(value: unknown): value is NotificationStatus {
return value === 'sent' || value === 'executed' || value === 'cancelled';
}
export function buildNotificationUpdate(
body: UpdateNotificationRequest,
id: number,
): { fields: string[]; params: (string | number | null)[] } {
const fields: string[] = ['status = ?'];
const params: (string | number | null)[] = [body.status];
if (body.status === 'executed') fields.push('executed_at = CURRENT_TIMESTAMP');
if (body.notes !== undefined) {
fields.push('notes = ?');
params.push(body.notes);
}
if (body.afterMileage !== undefined) {
fields.push('after_mileage = ?');
params.push(body.afterMileage);
}
params.push(id);
return { fields, params };
}
+20 -51
View File
@@ -9,31 +9,18 @@ import type {
NotificationStatus,
UpdateNotificationRequest,
} from './types.js';
import {
buildActiveNotificationMap,
buildNotificationUpdate,
hasRequiredNotifyFields,
isNotificationStatus,
rowToNotificationRecord,
type ActiveNotificationRow,
type NotificationDbRow,
} from './notification-model.js';
const app = new Hono();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rowToRecord(row: any): NotificationRecord {
return {
id: Number(row.id),
suggestionId: row.suggestion_id,
currentPlate: row.current_plate,
candidatePlate: row.candidate_plate,
operatorId: row.operator_id,
operatorName: row.operator_name,
status: row.status,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : '',
executedAt: row.executed_at ? new Date(row.executed_at).toISOString() : null,
notes: row.notes,
beforeMileage: row.before_mileage != null ? Number(row.before_mileage) : null,
afterMileage: row.after_mileage != null ? Number(row.after_mileage) : null,
};
}
/**
* Count non-cancelled interventions created within the last 7 days.
*/
@@ -60,14 +47,7 @@ export async function fetchActiveNotificationMap(): Promise<
ORDER BY created_at DESC`,
)) as [any[], unknown];
const map = new Map<string, { id: number; status: NotificationStatus }>();
for (const row of rows) {
const key = `${row.suggestion_id}::${row.candidate_plate}`;
if (!map.has(key)) {
map.set(key, { id: Number(row.id), status: row.status });
}
}
return map;
return buildActiveNotificationMap(rows as ActiveNotificationRow[]);
}
async function insertNotification(
@@ -101,7 +81,7 @@ async function insertNotification(
[insertedId],
)) as [any[], unknown];
return rowToRecord(rows[0]);
return rowToNotificationRecord(rows[0] as NotificationDbRow);
}
// ---------------------------------------------------------------------------
@@ -114,7 +94,7 @@ app.post('/', async (c) => {
const body = await c.req.json<NotifyRequest>();
const { suggestionId, currentPlate, candidatePlate } = body;
if (!suggestionId || !currentPlate || !candidatePlate) {
if (!hasRequiredNotifyFields(body)) {
return c.json({ success: false, message: '缺少必要参数' }, 400);
}
@@ -163,7 +143,7 @@ app.post('/batch', async (c) => {
const result: NotifyBatchResult = { success: 0, skipped: 0, failed: 0, records: [] };
for (const item of body.items) {
if (!item.suggestionId || !item.currentPlate || !item.candidatePlate) {
if (!hasRequiredNotifyFields(item)) {
result.failed++;
continue;
}
@@ -217,7 +197,7 @@ app.get('/', async (c) => {
params,
)) as [any[], unknown];
return c.json({ records: rows.map(rowToRecord) });
return c.json({ records: (rows as NotificationDbRow[]).map(rowToNotificationRecord) });
} catch (e: unknown) {
console.error('scheduling notifications list error:', e);
return c.json({ records: [] }, 500);
@@ -237,25 +217,11 @@ app.patch('/:id', async (c) => {
return c.json({ success: false, message: '缺少 status' }, 400);
}
const validStatuses: NotificationStatus[] = ['sent', 'executed', 'cancelled'];
if (!validStatuses.includes(body.status)) {
if (!isNotificationStatus(body.status)) {
return c.json({ success: false, message: 'status 不合法' }, 400);
}
const fields: string[] = ['status = ?'];
const params: (string | number | null)[] = [body.status];
if (body.status === 'executed') {
fields.push('executed_at = CURRENT_TIMESTAMP');
}
if (body.notes !== undefined) {
fields.push('notes = ?');
params.push(body.notes);
}
if (body.afterMileage !== undefined) {
fields.push('after_mileage = ?');
params.push(body.afterMileage);
}
params.push(id);
const { fields, params } = buildNotificationUpdate(body, id);
await pool.execute(
`UPDATE tab_scheduling_notifications SET ${fields.join(', ')} WHERE id = ?`,
@@ -271,7 +237,10 @@ app.patch('/:id', async (c) => {
return c.json({ success: false, message: '记录不存在' }, 404);
}
return c.json({ success: true, record: rowToRecord(rows[0]) });
return c.json({
success: true,
record: rowToNotificationRecord(rows[0] as NotificationDbRow),
});
} catch (e: unknown) {
console.error('scheduling notification update error:', e);
return c.json({ success: false, message: '更新失败' }, 500);
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildVehicleGrid,
buildVehicleRanking,
daysInclusive,
filterVehicleRecordsByRadius,
haversineKm,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js';
function record(overrides: Partial<VehicleHeatmapRecord> = {}): VehicleHeatmapRecord {
return {
date: '2026-07-01',
vin: 'VIN-A',
plate: '粤A00001',
time: '2026-07-01 08:00:00',
longitude: 120,
latitude: 30,
source: 'test',
sourceRecordId: '1',
...overrides,
};
}
test('车辆热力图查询继续使用固定默认日期和原有字符串处理规则', () => {
assert.deepEqual(parseVehicleHeatmapQuery({}), {
startDate: '2026-01-01',
endDate: '2026-07-13',
query: '',
batchModel: '',
metric: 'locations',
});
assert.deepEqual(parseVehicleHeatmapQuery({
startDate: '2026-06-01',
endDate: 'invalid',
query: ' 粤A ',
batchModel: ' 190台冷链 ',
metric: 'vehicles',
}), {
startDate: '2026-06-01',
endDate: '2026-07-13',
query: ' 粤A ',
batchModel: '190台冷链',
metric: 'vehicles',
});
});
test('附近查询继续沿用经纬度 Number 语义和 5 至 200 公里半径限制', () => {
assert.deepEqual(parseNearbyQuery({ lng: '', lat: '0', radiusKm: '0' }), {
center: { lng: 0, lat: 0 },
radiusKm: 50,
});
assert.deepEqual(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '-1' }), {
center: { lng: 120, lat: 30 },
radiusKm: 5,
});
assert.equal(parseNearbyQuery({ lng: '120', lat: '30', radiusKm: '500' }).radiusKm, 200);
assert.equal(parseNearbyQuery({ lng: 'bad', lat: '30' }).center, null);
});
test('网格精度继续按含首尾天数和筛选条件决定', () => {
assert.equal(daysInclusive('2026-07-01', '2026-07-14'), 14);
assert.equal(daysInclusive('2026-07-14', '2026-07-01'), 0);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-14', '', ''), 3);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', '', ''), 2);
assert.equal(vehicleGridPrecision('2026-07-01', '2026-07-15', ' ', ''), 3);
});
test('车辆网格继续区分位置次数和去重车辆数并保留插入顺序', () => {
const records = [
record({ vin: 'VIN-A', longitude: 120.1231, latitude: 30.9878 }),
record({ vin: 'VIN-A', longitude: 120.1232, latitude: 30.9879 }),
record({ vin: 'VIN-B', longitude: 120.1233, latitude: 30.9877 }),
record({ vin: 'VIN-C', longitude: 121, latitude: 31 }),
];
assert.deepEqual(buildVehicleGrid(records, 'locations', 3), {
points: [
{ lng: 120.123, lat: 30.988, count: 3 },
{ lng: 121, lat: 31, count: 1 },
],
max: 3,
});
assert.deepEqual(buildVehicleGrid(records, 'vehicles', 3), {
points: [
{ lng: 120.123, lat: 30.988, count: 2 },
{ lng: 121, lat: 31, count: 1 },
],
max: 2,
});
assert.deepEqual(buildVehicleGrid([], 'locations', 2), { points: [], max: 1 });
});
test('车辆排名继续按记录数、车牌排序并保留首末出现时间', () => {
const records = [
record({ vin: 'VIN-B', plate: '', time: '2026-07-02 10:00:00' }),
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-02 09:00:00' }),
record({ vin: 'VIN-B', plate: '粤B00001', time: '2026-07-01 08:00:00' }),
record({ vin: 'VIN-A', plate: '粤A00001', time: '2026-07-03 11:00:00' }),
];
assert.deepEqual(buildVehicleRanking(records), [
{
vin: 'VIN-A',
plateNumber: '粤A00001',
locationCount: 2,
firstSeen: '2026-07-02 09:00:00',
lastSeen: '2026-07-03 11:00:00',
},
{
vin: 'VIN-B',
plateNumber: '粤B00001',
locationCount: 2,
firstSeen: '2026-07-01 08:00:00',
lastSeen: '2026-07-02 10:00:00',
},
]);
assert.equal(buildVehicleRanking(records, 1).length, 1);
});
test('车辆统计和附近筛选继续使用 VIN、自然日去重及 Haversine 距离', () => {
const records = [
record({ vin: 'VIN-A', date: '2026-07-01' }),
record({ vin: 'VIN-A', date: '2026-07-02', longitude: 120.01 }),
record({ vin: 'VIN-B', date: '2026-07-02', longitude: 121 }),
];
assert.deepEqual(summarizeVehicleRecords(records), {
locationCount: 3,
vehicleCount: 2,
dayCount: 2,
});
assert.equal(haversineKm(120, 30, 120, 30), 0);
assert.ok(Math.abs(haversineKm(0, 0, 0, 1) - 111.195) < 0.01);
assert.deepEqual(
filterVehicleRecordsByRadius(records, { lng: 120, lat: 30 }, 5).map(({ vin, date }) => ({ vin, date })),
[
{ vin: 'VIN-A', date: '2026-07-01' },
{ vin: 'VIN-A', date: '2026-07-02' },
],
);
});
+165
View File
@@ -0,0 +1,165 @@
export type VehicleHeatmapMetric = 'locations' | 'vehicles';
export type VehicleHeatmapRecord = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
sourceRecordId: string;
};
export type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
type VehicleHeatmapQueryInput = {
startDate?: string;
endDate?: string;
query?: string;
batchModel?: string;
metric?: string;
};
type NearbyQueryInput = {
lng?: string;
lat?: string;
radiusKm?: string;
};
export const VEHICLE_HEATMAP_DEFAULT_START = '2026-01-01';
export const VEHICLE_HEATMAP_DEFAULT_END = '2026-07-13';
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
export function parseVehicleHeatmapQuery(input: VehicleHeatmapQueryInput) {
return {
startDate: isDate(input.startDate) ? input.startDate : VEHICLE_HEATMAP_DEFAULT_START,
endDate: isDate(input.endDate) ? input.endDate : VEHICLE_HEATMAP_DEFAULT_END,
query: input.query || '',
batchModel: (input.batchModel || '').trim(),
metric: input.metric === 'vehicles' ? 'vehicles' as const : 'locations' as const,
};
}
export function parseNearbyQuery(input: NearbyQueryInput) {
const longitude = Number(input.lng);
const latitude = Number(input.lat);
return {
center: Number.isFinite(longitude) && Number.isFinite(latitude)
? { lng: longitude, lat: latitude }
: null,
radiusKm: Math.min(200, Math.max(5, Number(input.radiusKm) || 50)),
};
}
export function daysInclusive(startDate: string, endDate: string): number {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
}
export function vehicleGridPrecision(
startDate: string,
endDate: string,
query: string,
batchModel: string,
): number {
return daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
}
export function buildVehicleGrid(
records: VehicleHeatmapRecord[],
metric: VehicleHeatmapMetric,
precision: number,
) {
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
for (const record of records) {
const lng = Number(record.longitude.toFixed(precision));
const lat = Number(record.latitude.toFixed(precision));
const key = `${lng},${lat}`;
let point = grid.get(key);
if (!point) {
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
grid.set(key, point);
}
if (point.vins) {
point.vins.add(record.vin);
point.count = point.vins.size;
} else {
point.count += 1;
}
}
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
let max = 1;
for (const point of points) max = Math.max(max, point.count);
return { points, max };
}
export function buildVehicleRanking(records: VehicleHeatmapRecord[], limit = 10): VehicleRank[] {
const vehicles = new Map<string, VehicleRank>();
for (const record of records) {
const current = vehicles.get(record.vin);
if (!current) {
vehicles.set(record.vin, {
vin: record.vin,
plateNumber: record.plate,
locationCount: 1,
firstSeen: record.time,
lastSeen: record.time,
});
continue;
}
current.locationCount += 1;
if (record.time < current.firstSeen) current.firstSeen = record.time;
if (record.time > current.lastSeen) current.lastSeen = record.time;
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
}
return [...vehicles.values()]
.sort((left, right) => right.locationCount - left.locationCount
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
.slice(0, limit);
}
export function summarizeVehicleRecords(records: VehicleHeatmapRecord[]) {
return {
locationCount: records.length,
vehicleCount: new Set(records.map((record) => record.vin)).size,
dayCount: new Set(records.map((record) => record.date)).size,
};
}
export function haversineKm(
leftLng: number,
leftLat: number,
rightLng: number,
rightLat: number,
): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function filterVehicleRecordsByRadius(
records: VehicleHeatmapRecord[],
center: { lng: number; lat: number },
radiusKm: number,
): VehicleHeatmapRecord[] {
return records.filter((record) => (
haversineKm(center.lng, center.lat, record.longitude, record.latitude) <= radiusKm
));
}
+49 -114
View File
@@ -2,25 +2,16 @@ import { Hono } from 'hono';
import pool from '../db.js';
import heatmapPool from '../heatmap-db.js';
import { getCache } from './mileage/cache.js';
type HeatmapRecord = {
date: string;
vin: string;
plate: string;
time: string;
longitude: number;
latitude: number;
source: string;
sourceRecordId: string;
};
type VehicleRank = {
vin: string;
plateNumber: string;
locationCount: number;
firstSeen: string;
lastSeen: string;
};
import {
buildVehicleGrid,
buildVehicleRanking,
filterVehicleRecordsByRadius,
parseNearbyQuery,
parseVehicleHeatmapQuery,
summarizeVehicleRecords,
vehicleGridPrecision,
type VehicleHeatmapRecord,
} from './vehicle-heatmap-model.js';
type MetaRow = {
start_date: string;
@@ -55,16 +46,6 @@ type BatchModelRow = {
plate_number: string;
};
function isDate(value: string | undefined): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function daysInclusive(startDate: string, endDate: string): number {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
return Math.max(0, Math.floor((end - start) / 86_400_000) + 1);
}
async function loadBatchModelPlates(): Promise<Map<string, Set<string>>> {
const cached = getCache()?.targetPlatesMap;
if (cached?.size) return cached;
@@ -95,7 +76,7 @@ async function loadRecords(
endDate: string,
query: string,
batchModel: string,
): Promise<HeatmapRecord[]> {
): Promise<VehicleHeatmapRecord[]> {
let batchPlates: string[] = [];
if (batchModel) {
const modelPlates = await loadBatchModelPlates();
@@ -134,41 +115,6 @@ async function loadRecords(
}));
}
function buildRanking(records: HeatmapRecord[], limit = 10): VehicleRank[] {
const vehicles = new Map<string, VehicleRank>();
for (const record of records) {
const current = vehicles.get(record.vin);
if (!current) {
vehicles.set(record.vin, {
vin: record.vin,
plateNumber: record.plate,
locationCount: 1,
firstSeen: record.time,
lastSeen: record.time,
});
continue;
}
current.locationCount += 1;
if (record.time < current.firstSeen) current.firstSeen = record.time;
if (record.time > current.lastSeen) current.lastSeen = record.time;
if (!current.plateNumber && record.plate) current.plateNumber = record.plate;
}
return [...vehicles.values()]
.sort((left, right) => right.locationCount - left.locationCount
|| left.plateNumber.localeCompare(right.plateNumber, 'zh-CN'))
.slice(0, limit);
}
function haversineKm(leftLng: number, leftLat: number, rightLng: number, rightLat: number): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const earthRadius = 6371;
const dLat = radians(rightLat - leftLat);
const dLng = radians(rightLng - leftLng);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(radians(leftLat)) * Math.cos(radians(rightLat)) * Math.sin(dLng / 2) ** 2;
return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
const router = new Hono();
router.get('/config', (c) => {
@@ -223,76 +169,65 @@ router.get('/meta', async (c) => {
});
router.get('/points', async (c) => {
const requestedStart = c.req.query('startDate');
const requestedEnd = c.req.query('endDate');
const startDate = isDate(requestedStart) ? requestedStart : '2026-01-01';
const endDate = isDate(requestedEnd) ? requestedEnd : '2026-07-13';
const {
startDate,
endDate,
query,
batchModel,
metric,
} = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
metric: c.req.query('metric'),
});
if (startDate > endDate) return c.json({ error: '开始日期不能晚于结束日期' }, 400);
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const metric = c.req.query('metric') === 'vehicles' ? 'vehicles' : 'locations';
const records = await loadRecords(startDate, endDate, query, batchModel);
const precision = daysInclusive(startDate, endDate) <= 14 || Boolean(query || batchModel) ? 3 : 2;
const grid = new Map<string, { lng: number; lat: number; count: number; vins?: Set<string> }>();
for (const record of records) {
const lng = Number(record.longitude.toFixed(precision));
const lat = Number(record.latitude.toFixed(precision));
const key = `${lng},${lat}`;
let point = grid.get(key);
if (!point) {
point = { lng, lat, count: 0, vins: metric === 'vehicles' ? new Set<string>() : undefined };
grid.set(key, point);
}
if (point.vins) {
point.vins.add(record.vin);
point.count = point.vins.size;
} else {
point.count += 1;
}
}
const points = [...grid.values()].map(({ lng, lat, count }) => ({ lng, lat, count }));
let max = 1;
for (const point of points) max = Math.max(max, point.count);
const precision = vehicleGridPrecision(startDate, endDate, query, batchModel);
const { points, max } = buildVehicleGrid(records, metric, precision);
const summary = summarizeVehicleRecords(records);
return c.json({
startDate,
endDate,
metric,
locationCount: records.length,
vehicleCount: new Set(records.map((record) => record.vin)).size,
dayCount: new Set(records.map((record) => record.date)).size,
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
dayCount: summary.dayCount,
points,
max,
topVehicles: buildRanking(records),
topVehicles: buildVehicleRanking(records),
});
});
router.get('/nearby', async (c) => {
const longitude = Number(c.req.query('lng'));
const latitude = Number(c.req.query('lat'));
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
const { center, radiusKm } = parseNearbyQuery({
lng: c.req.query('lng'),
lat: c.req.query('lat'),
radiusKm: c.req.query('radiusKm'),
});
if (!center) {
return c.json({ error: '经纬度参数无效' }, 400);
}
const startDate = isDate(c.req.query('startDate')) ? c.req.query('startDate')! : '2026-01-01';
const endDate = isDate(c.req.query('endDate')) ? c.req.query('endDate')! : '2026-07-13';
const query = c.req.query('query') || '';
const batchModel = (c.req.query('batchModel') || '').trim();
const radiusKm = Math.min(200, Math.max(5, Number(c.req.query('radiusKm')) || 50));
const { startDate, endDate, query, batchModel } = parseVehicleHeatmapQuery({
startDate: c.req.query('startDate'),
endDate: c.req.query('endDate'),
query: c.req.query('query'),
batchModel: c.req.query('batchModel'),
});
const records = await loadRecords(startDate, endDate, query, batchModel);
const nearby = records.filter((record) => (
haversineKm(longitude, latitude, record.longitude, record.latitude) <= radiusKm
));
const nearby = filterVehicleRecordsByRadius(records, center, radiusKm);
const summary = summarizeVehicleRecords(nearby);
return c.json({
center: { lng: longitude, lat: latitude },
center,
radiusKm,
locationCount: nearby.length,
vehicleCount: new Set(nearby.map((record) => record.vin)).size,
topVehicles: buildRanking(nearby),
locationCount: summary.locationCount,
vehicleCount: summary.vehicleCount,
topVehicles: buildVehicleRanking(nearby),
});
});
+42 -797
View File
@@ -1,7 +1,5 @@
import { Hono } from 'hono';
import pool from '../db.js';
import type {
VehicleRow,
Vehicle,
SummaryData,
TypeSummary,
@@ -13,362 +11,33 @@ import type {
import { filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
import type { AuthUser } from '../auth/types.js';
import type { Context } from 'hono';
import {
INVENTORY_REGIONS,
SUMMARY_TYPE_FILTERS,
VEHICLE_TYPE_FILTERS,
classifyVehicleType,
compareModelNames,
filterByLocation,
mapInventoryRegion,
mapMacroRegion,
resolveCity,
} from './vehicles/model.js';
import {
compareDepartmentNames,
countByType,
getMileageStats,
getStats,
listDateRange,
normalizeDateRange,
} from './vehicles/utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js';
export { mapRegion } from './vehicles/model.js';
const app = new Hono();
const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id,
vi.plate_number AS 车牌号,
vi.vin AS vin,
vm.brand AS 车辆品牌,
vm.model AS 车辆型号,
vi.body_color AS 车辆颜色,
vi.rental_company AS 租赁公司,
CASE vi.vehicle_source
WHEN '0' THEN '自有'
WHEN '1' THEN '挂靠'
WHEN '2' THEN '外租'
WHEN '3' THEN '自有'
ELSE vi.actual_ownership
END AS 车辆归属状态Label,
vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
info.lat AS 纬度,
info.lng AS 经度,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number
AND info.is_deleted = 0
LEFT JOIN common_district info_province
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND info_province.STATUS = 'VALID'
LEFT JOIN common_district info_city
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND info_city.STATUS = 'VALID'
LEFT JOIN common_district vi_province
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
AND vi_province.STATUS = 'VALID'
LEFT JOIN common_district vi_city
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
AND vi_city.STATUS = 'VALID'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
// Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
export function mapRegion(province: string | null, city: string | null): string {
if (!province && !city) return '其他';
const loc = (city || province || '').trim();
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
if (loc.includes('广东') || loc.includes('广州') || loc.includes('深圳') || loc.includes('佛山') || loc.includes('东莞')) return '广东';
if (loc.includes('北京')) return '北京';
if (loc.includes('新疆') || loc.includes('乌鲁木齐')) return '新疆';
// Also check province
const prov = (province || '').trim();
if (prov.includes('浙江') || prov.includes('上海') || prov.includes('江苏')) return '嘉兴';
if (prov.includes('广东')) return '广东';
if (prov.includes('北京')) return '北京';
if (prov.includes('新疆')) return '新疆';
return '其他';
}
function mapInventoryRegion(region: string): string {
if (region === '嘉兴') return '江浙沪';
if (region === '广东') return '广东';
if (region === '新疆') return '新疆';
return '其它';
}
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const c = (city || '').trim();
const loc = prov + c;
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
return '其他';
}
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
if (v.type === '18T') return 't18';
if (v.type === '49T') return 't49';
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
return 'other';
}
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
for (const v of vehicles) {
counts[classifyVehicleType(v)]++;
counts.total++;
}
return counts;
}
// Map operation status to frontend status.
// ln_asset_management.vehicle_status.operation_status:
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
const op = (operationStatus || '').trim();
const vehicle = (vehicleStatus || '').trim();
if (vehicle === '4') return 'Pending';
if (vehicle === '14') return 'Abnormal';
if (op === '1' || op === '2') return 'Operating';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership from vehicle_info.vehicle_source.
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Hanging';
if (s === '2') return 'Leased';
if (s === '3') return 'Self';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging';
return 'Unknown';
}
// Resolve city name: clean brackets, fallback to province for municipalities
function resolveCity(city: string | null, province: string | null): string {
const c = (city || '').replace(/[\[\]]/g, '').trim();
if (c) return c;
const p = (province || '').replace(/[\[\]]/g, '').trim();
return p || '其他';
}
// Derive page category from ln_asset_management.vehicle_model.
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车';
return '其他车型';
}
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Tag → alias mapping with sort order
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
// 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
'跃进-4.5吨冷链车-白/绿/灰': { alias: '跃进4.5T冷链', order: 203 },
// 18T
'飞驰-18吨厢式货车-红': { alias: '飞驰18T(红车)', order: 301 },
'飞驰-18吨厢式货车-白/绿': { alias: '飞驰18T(白车)', order: 302 },
'楚风-18吨厢式货车-白': { alias: '楚风18T厢货', order: 303 },
'苏龙-18吨双飞翼货车-白': { alias: '苏龙18T飞翼', order: 304 },
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼(羚牛)', order: 306 },
// 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰(红)', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
// 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
'其他-氢能叉车-蓝白绿': { alias: '氢能叉车', order: 502 },
'通华-重型集装箱半挂车-红色浙江锦昌仓储有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色嘉兴市鼎义物流有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'大通-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红': { alias: '挂车', order: 503 },
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
};
function deriveModelTag(
brandLabel: string | null,
modelLabel: string | null,
color: string | null,
ownershipLabel: string | null,
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim();
const c = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : '';
if (!brand && !model) return '未知车型';
const tag = `${brand}-${model}-${c}${company}`;
const mapped = MODEL_ALIAS_MAP[tag];
return mapped ? mapped.alias : tag;
}
function getModelOrder(model: string): number {
// Find the order from alias mapping
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
if (entry.alias === model) return entry.order;
}
return 999;
}
function transformRow(row: VehicleRow): Vehicle {
const region = mapRegion(row., row.);
return {
id: row.id,
plateNumber: row.车牌号 || '',
vin: row.vin || '',
type: deriveType(row.Label, row.),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.Label, row.),
operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
orgName: row.合同归属公司,
departmentName: row.合同归属部门,
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
managerId: row.经理ID || null,
brandLabel: row.车辆品牌Label,
};
}
// Cache for vehicles data (refresh every 5 minutes)
let cachedVehicles: Vehicle[] = [];
let lastFetchTime = 0;
const CACHE_TTL = 60 * 1000;
async function getVehicles(): Promise<Vehicle[]> {
const now = Date.now();
if (cachedVehicles.length > 0 && now - lastFetchTime < CACHE_TTL) {
return cachedVehicles;
}
const [rows] = await pool.query<any[]>(MAIN_SQL);
cachedVehicles = (rows as VehicleRow[]).map(transformRow);
lastFetchTime = now;
return cachedVehicles;
}
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
const all = await getVehicles();
const all = await vehicleRepository.getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
let list = user ? filterByPermission(all, user) : all;
list = applySubjectFilter(c, list);
@@ -387,239 +56,9 @@ function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
return vehicles.filter((v) => (v.subjectOrg || '') === subject);
}
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
return regions.reduce((acc, reg) => {
acc[reg] = vehicles.filter((v) => v.location === reg).length;
return acc;
}, {} as Record<string, number>);
}
// Weekly truck ID sets, cached
interface WeeklyTruckIds {
pending: Set<string>;
delivered: Set<string>;
returned: Set<string>;
replaced: Set<string>;
}
let cachedWeeklyTruckIds: WeeklyTruckIds | null = null;
let weeklyTruckIdsLastFetch = 0;
async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
const now = Date.now();
if (cachedWeeklyTruckIds && now - weeklyTruckIdsLastFetch < CACHE_TTL) {
return cachedWeeklyTruckIds;
}
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM vehicle_status
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM delivery_vehicle
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
AND delivery_status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM return_vehicle_task
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
AND status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
FROM vehicle_replacement
WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
cachedWeeklyTruckIds = {
pending: toSet(pendingRows as any[]),
delivered: toSet(deliveredRows as any[]),
returned: toSet(returnedRows as any[]),
replaced: toSet(replacedRows as any[]),
};
weeklyTruckIdsLastFetch = now;
return cachedWeeklyTruckIds;
}
function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const strIds = list.map((v) => String(v.id));
return {
total: list.length,
inventory: list.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
inventoryRegions: getRegionCounts(
list.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'),
REGIONS,
),
pending: list.filter((v) => v.status === 'Pending').length,
operating: list.filter((v) => v.status === 'Operating').length,
weeklyDelivered: weeklyIds ? strIds.filter((id) => weeklyIds.delivered.has(id)).length : 0,
weeklyReturned: weeklyIds ? strIds.filter((id) => weeklyIds.returned.has(id)).length : 0,
weeklyReplaced: weeklyIds ? strIds.filter((id) => weeklyIds.replaced.has(id)).length : 0,
};
}
// Week range: last Saturday 00:00 to this Friday 23:59
// MySQL: WEEKDAY() returns 0=Monday..6=Sunday, Saturday=5
// "上周六-本周五": offset from today to last Saturday
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
weeklyRemoved: number;
weeklyDelivered: number;
weeklyReturned: number;
weeklyReplaced: number;
}
// 交车单 SQL
const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)`;
// 还车单 SQL
const RETURNED_SQL = `SELECT
r.id, DATE(r.arrival_time) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.arrival_time IS NOT NULL
AND r.status IN (2,3,5)`;
// 替换车单 SQL
const REPLACED_SQL = `SELECT
vr.id, DATE(vr.replace_time) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
c.contract_type,
c.customer_name
FROM vehicle_replacement vr
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.replace_time IS NOT NULL
AND vr.status = 20`;
let cachedWeeklyStats: WeeklyStats | null = null;
let weeklyStatsLastFetch = 0;
async function getWeeklyStats(): Promise<WeeklyStats> {
const now = Date.now();
if (cachedWeeklyStats && now - weeklyStatsLastFetch < CACHE_TTL) {
return cachedWeeklyStats;
}
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
]);
cachedWeeklyStats = {
pendingDelivery: (pendingRows as any[])[0]?.cnt || 0,
weeklyNew: (newRows as any[])[0]?.cnt || 0,
weeklyRemoved: (removedRows as any[])[0]?.cnt || 0,
weeklyDelivered: (deliveredRows as any[])[0]?.cnt || 0,
weeklyReturned: (returnedRows as any[])[0]?.cnt || 0,
weeklyReplaced: (replacedRows as any[])[0]?.cnt || 0,
};
weeklyStatsLastFetch = now;
return cachedWeeklyStats;
}
// GET /api/vehicles/summary
app.get('/summary', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), getWeeklyTruckIds()]);
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
const summary: SummaryData = {
totalAssets: vehicles.length,
@@ -647,17 +86,9 @@ app.get('/summary', async (c) => {
// GET /api/vehicles/by-type
app.get('/by-type', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), getWeeklyTruckIds()]);
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
];
const result: TypeSummary[] = typeFilters.map((t) => {
const result: TypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
const typeVehicles = vehicles.filter(t.filter);
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
@@ -686,7 +117,7 @@ app.get('/by-type', async (c) => {
weeklyDelivered: typeStats.weeklyDelivered,
weeklyReturned: typeStats.weeklyReturned,
weeklyReplaced: typeStats.weeklyReplaced,
models: modelSummaries.sort((a, b) => getModelOrder(a.model) - getModelOrder(b.model)),
models: modelSummaries.sort((a, b) => compareModelNames(a.model, b.model)),
};
});
@@ -724,17 +155,9 @@ app.get('/by-batch', async (c) => {
// GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放
app.get('/inventory-analysis', async (c) => {
const vehicles = applySubjectFilter(c, await getVehicles());
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
];
const result: InventoryTypeSummary[] = typeFilters.map((t) => {
const result: InventoryTypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
const typeVehicles = vehicles.filter(t.filter);
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
@@ -782,11 +205,7 @@ app.get('/dept-stats', async (c) => {
const withManager = vehicles.filter((v) => v.status === 'Operating');
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
const [realtimeRows] = await pool.query<any[]>(`
SELECT plate_number, day_mileage
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`);
const realtimeRows = await vehicleRepository.getRealtimeMileageRows();
const todayMileageMap = new Map<string, number>();
for (const row of realtimeRows as any[]) {
const plate = (row.plate_number || '').trim();
@@ -808,27 +227,9 @@ app.get('/dept-stats', async (c) => {
mgrMap.get(mgr)!.push(v);
}
// Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
let totalMileage = 0;
let count = 0;
for (const v of vList) {
const m = todayMileageMap.get(v.plateNumber);
if (m !== undefined && m > 0) {
totalMileage += m;
count++;
}
}
return {
attendanceRate: vList.length > 0 ? Math.round((todayActive / vList.length) * 1000) / 10 : 0,
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
};
};
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
const deptMileage = getMileageStats(allDeptVehicles);
const deptMileage = getMileageStats(allDeptVehicles, todayMileageMap);
const managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
.sort((a, b) => b.total - a.total);
@@ -841,16 +242,7 @@ app.get('/dept-stats', async (c) => {
avgMileage: deptMileage.avgMileage,
managers,
};
}).sort((a, b) => {
// 按部门名中的数字排序(业务一部=1, 业务二部=2, ...)
const numMap: Record<string, number> = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
const getNum = (name: string) => {
if (name === '公务车') return 100;
const m = name.match(/[一二三四五六七八九十]/);
return m ? (numMap[m[0]] || 99) : 99;
};
return getNum(a.department) - getNum(b.department);
});
}).sort((a, b) => compareDepartmentNames(a.department, b.department));
return c.json(result);
});
@@ -971,31 +363,6 @@ app.get('/customer-stats', async (c) => {
return c.json(result);
});
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
// 用 source 区分:source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
if (isMacro) {
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
}
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
// Vehicle type filter map (same logic as /by-type)
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
'4.5T冷链': (v) => v.type === '4.5T' && v.model.includes('冷链'),
'18T': (v) => v.type === '18T',
'49T': (v) => v.type === '49T',
'挂车': (v) => v.type === '挂车' || v.model.includes('挂车'),
'其他': (v) => classifyVehicleType(v) === 'other',
};
// GET /api/vehicles/list — flat list with optional filters
app.get('/list', async (c) => {
const vehicles = await getVehiclesForUser(c);
@@ -1005,7 +372,7 @@ app.get('/list', async (c) => {
// attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles)
if (attendance === 'active' || attendance === 'idle') {
const [realtimeRows] = await pool.query<any[]>(`SELECT plate_number, day_mileage FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
const realtimeRows = await vehicleRepository.getAttendanceMileageRows();
const todayMap = new Map<string, number>();
for (const row of realtimeRows as any[]) todayMap.set((row.plate_number || '').trim(), Number(row.day_mileage) || 0);
filtered = filtered.filter((v) => v.status === 'Operating');
@@ -1093,7 +460,7 @@ app.get('/list', async (c) => {
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => {
const vehicles = applySubjectFilter(c, await getVehicles());
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
const TYPE_NAME_MAP: Record<string, string> = {
@@ -1133,29 +500,11 @@ app.get('/weekly-detail', async (c) => {
const type = c.req.query('type');
const { model, batch, location } = c.req.query();
const source = c.req.query('source');
let sql: string;
if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
} else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
} else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
} else if (type === 'pending') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
} else if (type === 'new') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
} else {
const validTypes: WeeklyDetailType[] = ['delivered', 'returned', 'replaced', 'pending', 'new'];
if (!validTypes.includes(type as WeeklyDetailType)) {
return c.json([]);
}
const [rows] = await pool.query<any[]>(sql);
let result = rows as any[];
let result = await vehicleRepository.getWeeklyDetailRows(type as WeeklyDetailType);
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
const hasModelFilter = model && model !== 'All';
@@ -1182,95 +531,8 @@ app.get('/flow-stats', async (c) => {
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
const rows = await vehicleRepository.getFlowDetailRows(start, end);
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, start, end]);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
@@ -1312,7 +574,7 @@ app.get('/flow-stats', async (c) => {
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
const all = await getVehicles();
const all = await vehicleRepository.getVehicles();
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
const visible = user ? filterByPermission(all, user) : all;
@@ -1336,31 +598,14 @@ app.get('/subjects', async (c) => {
// GET /api/vehicles/refresh — force cache refresh
app.get('/refresh', async (c) => {
lastFetchTime = 0;
weeklyStatsLastFetch = 0;
vehicleRepository.invalidateRefreshCaches();
const vehicles = await getVehiclesForUser(c);
return c.json({ message: 'Cache refreshed', count: vehicles.length });
});
// GET /api/vehicles/debug — debug weekly date range and raw counts
app.get('/debug', async (c) => {
const [[dateRange]] = await pool.query<any[]>(`SELECT
${WEEK_START_SQL} AS week_start,
${WEEK_END_SQL} AS week_end,
CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
const { dateRange, deliveredAll, deliveredRecent, latestTake, returnedRecent, latestReturn } = await vehicleRepository.getWeeklyDebugData();
return c.json({
weekRange: dateRange,
@@ -1388,7 +633,7 @@ app.get('/region-chart', async (c) => {
}
} else {
// Use realtime table province
const [rows] = await pool.query<any[]>(`SELECT plate_number, province FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
const rows = await vehicleRepository.getRealtimeProvinceRows();
const plateProvince = new Map<string, string>();
for (const row of rows as any[]) {
const plate = (row.plate_number || '').trim();
+249
View File
@@ -0,0 +1,249 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Vehicle, VehicleRow } from '../../types.js';
import {
SUMMARY_TYPE_FILTERS,
VEHICLE_TYPE_FILTERS,
classifyVehicleType,
compareModelNames,
deriveModelTag,
deriveType,
filterByLocation,
mapInventoryRegion,
mapMacroRegion,
mapOwnership,
mapRegion,
mapStatus,
resolveCity,
transformRow,
} from './model.js';
import {
addDateDays,
compareDepartmentNames,
countByType,
getMileageStats,
getStats,
isDateParam,
listDateRange,
normalizeDateRange,
} from './utils.js';
function vehicle(overrides: Partial<Vehicle> = {}): Vehicle {
return {
id: '1',
plateNumber: '粤A00001',
vin: 'VIN-1',
type: '4.5T',
model: '现代4.5T普货',
color: '白色',
location: '广东',
region: '广东',
province: '广东省',
city: '广州市',
status: 'Operating',
operationStatus: '2',
ownership: 'Self',
rentCompany: '',
contractNo: null,
customerName: null,
orgName: null,
departmentName: null,
subjectOrg: null,
projectName: null,
customerManager: null,
managerId: null,
brandLabel: null,
...overrides,
};
}
test('preserves display, inventory and macro region mappings', () => {
assert.equal(mapRegion(null, null), '其他');
assert.equal(mapRegion('广东省', '杭州市'), '广东');
assert.equal(mapRegion('广东省', '浙江省'), '嘉兴');
assert.equal(mapRegion('广东省', '未知市'), '广东');
assert.equal(mapRegion(null, '乌鲁木齐市'), '新疆');
assert.equal(mapInventoryRegion('嘉兴'), '江浙沪');
assert.equal(mapInventoryRegion('北京'), '其它');
assert.equal(mapMacroRegion('山东省', '青岛市'), '华东');
assert.equal(mapMacroRegion(null, '成都市'), '西南');
assert.equal(mapMacroRegion(null, null), '其他');
assert.equal(resolveCity('[北京市]', null), '北京市');
assert.equal(resolveCity(null, '[上海市]'), '上海市');
});
test('keeps vehicle status precedence and ownership aliases', () => {
assert.equal(mapStatus('2', '4'), 'Pending');
assert.equal(mapStatus('1', '14'), 'Abnormal');
assert.equal(mapStatus('租赁', null), 'Operating');
assert.equal(mapStatus('待运营', null), 'Inventory');
assert.equal(mapStatus(null, null), 'Inventory');
assert.equal(mapOwnership('0'), 'Self');
assert.equal(mapOwnership('1'), 'Hanging');
assert.equal(mapOwnership('外租'), 'Leased');
assert.equal(mapOwnership(null), 'Unknown');
});
test('preserves type precedence, aliases and model sorting', () => {
assert.equal(deriveType('重型集装箱半挂车', '1'), '挂车');
assert.equal(deriveType('4.5吨货车', '3'), '4.5T');
assert.equal(deriveType('氢能叉车', null), '叉车');
assert.equal(deriveModelTag('现代', '4.5吨货车', '白色', '自有', '忽略的公司'), '现代4.5T普货');
assert.equal(
deriveModelTag('现代', '4.5吨货车', '白色', '外租', '广州开发区交投氢能运营管理有限公司'),
'现代4.5T普货(交投)',
);
assert.equal(deriveModelTag(null, null, null, null, null), '未知车型');
const models = ['未知车型', '49T宇通', '现代4.5T普货(恒运)', '现代4.5T普货'];
assert.deepEqual(models.sort(compareModelNames), [
'现代4.5T普货',
'现代4.5T普货(恒运)',
'49T宇通',
'未知车型',
]);
});
test('keeps summary and list meanings of other vehicle types distinct', () => {
const trailer = vehicle({ type: '挂车', model: '挂车' });
const forklift = vehicle({ type: '叉车', model: '氢能叉车' });
const coldChain = vehicle({ type: '4.5T', model: '现代4.5T冷链(羚牛)' });
const summaryOther = SUMMARY_TYPE_FILTERS.find((item) => item.name === '其他');
assert.equal(classifyVehicleType(trailer), 'trailer');
assert.equal(classifyVehicleType(forklift), 'other');
assert.equal(classifyVehicleType(coldChain), 't4_5c');
assert.equal(summaryOther?.filter(trailer), true);
assert.equal(VEHICLE_TYPE_FILTERS['其他'](trailer), false);
assert.equal(VEHICLE_TYPE_FILTERS['其他'](forklift), true);
});
test('keeps location filtering semantics for the ambiguous other region', () => {
const assetOtherInGuangdong = vehicle({ id: 'asset-other', location: '其他', province: '广东省', city: '广州市' });
const macroOther = vehicle({ id: 'macro-other', location: '其他', province: null, city: null });
const jiaxing = vehicle({ id: 'jiaxing', location: '嘉兴', province: '浙江省', city: '嘉兴市' });
const vehicles = [assetOtherInGuangdong, macroOther, jiaxing];
assert.deepEqual(filterByLocation(vehicles, '其他', 'asset').map((item) => item.id), ['asset-other', 'macro-other']);
assert.deepEqual(filterByLocation(vehicles, '其他').map((item) => item.id), ['macro-other']);
assert.deepEqual(filterByLocation(vehicles, '江浙沪').map((item) => item.id), ['jiaxing']);
});
test('transforms database rows without changing the route model contract', () => {
const row: VehicleRow = {
id: '88',
: '粤A00088',
vin: 'VIN-88',
: 'hyundai',
: 'raw-model',
: '白色',
: '',
Label: '自有',
Label: '4.5吨货车',
: '1',
库存区域: null,
车辆租赁状态: null,
Label: '自营',
是否营运: 1,
: '广东省',
: '广州市',
纬度: null,
经度: null,
Label: '现代',
合同ID: null,
: 'HT-1',
: '客户',
: '公司',
: '业务一部',
: '主体',
: '项目',
: '经理',
ID: '9',
};
assert.deepEqual(transformRow(row), {
id: '88',
plateNumber: '粤A00088',
vin: 'VIN-88',
type: '4.5T',
model: '现代4.5T普货',
color: '白色',
location: '广东',
region: '广东',
province: '广东省',
city: '广州市',
status: 'Operating',
operationStatus: '自营',
ownership: 'Self',
rentCompany: '',
contractNo: 'HT-1',
customerName: '客户',
orgName: '公司',
departmentName: '业务一部',
subjectOrg: '主体',
projectName: '项目',
customerManager: '经理',
managerId: '9',
brandLabel: '现代',
});
});
test('preserves inventory, weekly-flow and mileage aggregation boundaries', () => {
const vehicles = [
vehicle({ id: '1', plateNumber: 'A', status: 'Operating', type: '4.5T', model: '普货' }),
vehicle({ id: '2', plateNumber: 'B', status: 'Inventory', location: '嘉兴', type: '4.5T', model: '冷链' }),
vehicle({ id: '3', plateNumber: 'C', status: 'Abnormal', location: '北京', type: '挂车', model: '挂车' }),
vehicle({ id: '4', plateNumber: 'D', status: 'Pending', type: '叉车', model: '叉车' }),
];
const weeklyIds = {
pending: new Set<string>(),
delivered: new Set(['1', '9']),
returned: new Set(['2']),
replaced: new Set(['3']),
};
assert.deepEqual(countByType(vehicles), {
t4_5: 1,
t4_5c: 1,
t18: 0,
t49: 0,
trailer: 1,
other: 1,
total: 4,
});
assert.deepEqual(getStats(vehicles, weeklyIds), {
total: 4,
inventory: 2,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 },
pending: 1,
operating: 1,
weeklyDelivered: 1,
weeklyReturned: 1,
weeklyReplaced: 1,
});
assert.deepEqual(getMileageStats(vehicles, new Map([['A', 100.4], ['B', 0], ['C', -5], ['D', 200.4]])), {
attendanceRate: 50,
avgMileage: 150,
});
});
test('preserves date validation, inclusive ranges, caps and department sorting', () => {
assert.equal(isDateParam('2026-08-13'), true);
assert.equal(isDateParam('2026-13-99'), true);
assert.equal(isDateParam('2026/08/13'), false);
assert.equal(addDateDays('2024-02-28', 1), '2024-02-29');
assert.deepEqual(listDateRange('2026-08-11', '2026-08-13'), ['2026-08-11', '2026-08-12', '2026-08-13']);
assert.deepEqual(normalizeDateRange(undefined, undefined, new Date(2026, 7, 13, 12)), {
start: '2026-07-15',
end: '2026-08-13',
});
assert.deepEqual(normalizeDateRange('2026-08-10', '2026-08-01'), {
start: '2026-08-01',
end: '2026-08-10',
});
const capped = normalizeDateRange('2020-01-01', '2026-08-13');
assert.equal(listDateRange(capped.start, capped.end).length, 370);
const departments = ['公务车', '未分配部门', '业务十部', '业务二部', '业务一部'];
assert.deepEqual(departments.sort(compareDepartmentNames), ['业务一部', '业务二部', '业务十部', '未分配部门', '公务车']);
});
+254
View File
@@ -0,0 +1,254 @@
import type { Vehicle, VehicleRow } from '../../types.js';
import type { VehicleTypeCategory } from './types.js';
export const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
export const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
export function mapRegion(province: string | null, city: string | null): string {
if (!province && !city) return '其他';
const loc = (city || province || '').trim();
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
if (loc.includes('广东') || loc.includes('广州') || loc.includes('深圳') || loc.includes('佛山') || loc.includes('东莞')) return '广东';
if (loc.includes('北京')) return '北京';
if (loc.includes('新疆') || loc.includes('乌鲁木齐')) return '新疆';
const prov = (province || '').trim();
if (prov.includes('浙江') || prov.includes('上海') || prov.includes('江苏')) return '嘉兴';
if (prov.includes('广东')) return '广东';
if (prov.includes('北京')) return '北京';
if (prov.includes('新疆')) return '新疆';
return '其他';
}
export function mapInventoryRegion(region: string): string {
if (region === '嘉兴') return '江浙沪';
if (region === '广东') return '广东';
if (region === '新疆') return '新疆';
return '其它';
}
export function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const cityName = (city || '').trim();
const location = prov + cityName;
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(location)) return '华东';
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(location)) return '华南';
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(location)) return '华北';
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(location)) return '华中';
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(location)) return '西南';
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(location)) return '西北';
return '其他';
}
export function classifyVehicleType(vehicle: Vehicle): VehicleTypeCategory {
if (vehicle.type === '4.5T' && !vehicle.model.includes('冷链')) return 't4_5';
if (vehicle.type === '4.5T' && vehicle.model.includes('冷链')) return 't4_5c';
if (vehicle.type === '18T') return 't18';
if (vehicle.type === '49T') return 't49';
if (vehicle.type === '挂车' || vehicle.model.includes('挂车')) return 'trailer';
return 'other';
}
// vehicle_status has priority because pending (4) and abnormal (14) override operation_status.
// operation_status: 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
export function mapStatus(operationStatus: string | null, vehicleStatus: string | null): Vehicle['status'] {
const operation = (operationStatus || '').trim();
const vehicle = (vehicleStatus || '').trim();
if (vehicle === '4') return 'Pending';
if (vehicle === '14') return 'Abnormal';
if (operation === '1' || operation === '2') return 'Operating';
if (operation === '3' || operation === '4') return 'Inventory';
if (operation === '租赁' || operation === '自营') return 'Operating';
if (operation === '可运营' || operation === '待运营' || operation === '在库') return 'Inventory';
if (operation === '异动') return 'Abnormal';
return 'Inventory';
}
export function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const status = rentStatusLabel.trim();
if (status === '0') return 'Self';
if (status === '1') return 'Hanging';
if (status === '2') return 'Leased';
if (status === '3') return 'Self';
if (status === '自有') return 'Self';
if (status === '外租') return 'Leased';
if (status === '自营') return 'Self';
if (status === '租赁') return 'Leased';
if (status === '挂靠') return 'Hanging';
return 'Unknown';
}
export function resolveCity(city: string | null, province: string | null): string {
const cityName = (city || '').replace(/[\[\]]/g, '').trim();
if (cityName) return cityName;
const provinceName = (province || '').replace(/[\[\]]/g, '').trim();
return provinceName || '其他';
}
// vehicle_type is the new-system category code: 1=4.5T, 3=牵引车头, 5/6=挂车.
export function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车';
return '其他车型';
}
export function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Key format: brand-model-color[+rentCompany when ownership is 外租].
// Multiple source tags intentionally share aliases and order values to merge dirty or equivalent data.
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
// 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
'跃进-4.5吨冷链车-白/绿/灰': { alias: '跃进4.5T冷链', order: 203 },
// 18T
'飞驰-18吨厢式货车-红': { alias: '飞驰18T(红车)', order: 301 },
'飞驰-18吨厢式货车-白/绿': { alias: '飞驰18T(白车)', order: 302 },
'楚风-18吨厢式货车-白': { alias: '楚风18T厢货', order: 303 },
'苏龙-18吨双飞翼货车-白': { alias: '苏龙18T飞翼', order: 304 },
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 },
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼(羚牛)', order: 306 },
// 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰(红)', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
// 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
'其他-氢能叉车-蓝白绿': { alias: '氢能叉车', order: 502 },
'通华-重型集装箱半挂车-红色浙江锦昌仓储有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色嘉兴市鼎义物流有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'大通-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红': { alias: '挂车', order: 503 },
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
};
export function deriveModelTag(
brandLabel: string | null,
modelLabel: string | null,
color: string | null,
ownershipLabel: string | null,
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim();
const vehicleColor = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const company = ownershipLabel?.trim() === '外租' ? (rentCompany || '').trim() : '';
if (!brand && !model) return '未知车型';
const tag = `${brand}-${model}-${vehicleColor}${company}`;
return MODEL_ALIAS_MAP[tag]?.alias || tag;
}
export function getModelOrder(model: string): number {
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
if (entry.alias === model) return entry.order;
}
return 999;
}
export function compareModelNames(left: string, right: string): number {
return getModelOrder(left) - getModelOrder(right);
}
export function transformRow(row: VehicleRow): Vehicle {
const region = mapRegion(row., row.);
return {
id: row.id,
plateNumber: row.车牌号 || '',
vin: row.vin || '',
type: deriveType(row.Label, row.),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.Label, row.),
operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
orgName: row.合同归属公司,
departmentName: row.合同归属部门,
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
managerId: row.经理ID || null,
brandLabel: row.车辆品牌Label,
};
}
export const SUMMARY_TYPE_FILTERS: ReadonlyArray<{ name: string; filter: (vehicle: Vehicle) => boolean }> = [
{ name: '4.5T普货', filter: (vehicle) => vehicle.type === '4.5T' && !vehicle.model.includes('冷链') },
{ name: '4.5T冷链', filter: (vehicle) => vehicle.type === '4.5T' && vehicle.model.includes('冷链') },
{ name: '18T', filter: (vehicle) => vehicle.type === '18T' },
{ name: '49T', filter: (vehicle) => vehicle.type === '49T' },
{ name: '其他', filter: (vehicle) => !['4.5T', '18T', '49T'].includes(vehicle.type) },
];
// List filtering keeps trailers separate, while summary grouping intentionally includes them in “其他”.
export const VEHICLE_TYPE_FILTERS: Record<string, (vehicle: Vehicle) => boolean> = {
'4.5T普货': (vehicle) => vehicle.type === '4.5T' && !vehicle.model.includes('冷链'),
'4.5T冷链': (vehicle) => vehicle.type === '4.5T' && vehicle.model.includes('冷链'),
'18T': (vehicle) => vehicle.type === '18T',
'49T': (vehicle) => vehicle.type === '49T',
'挂车': (vehicle) => vehicle.type === '挂车' || vehicle.model.includes('挂车'),
'其他': (vehicle) => classifyVehicleType(vehicle) === 'other',
};
export function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
if (isMacro) {
return vehicles.filter((vehicle) => mapMacroRegion(vehicle.province, vehicle.city) === location);
}
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
return vehicles.filter(
(vehicle) => vehicle.location === mappedLocation
|| vehicle.city === location
|| resolveCity(vehicle.city, vehicle.province) === location,
);
}
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import type { Vehicle, VehicleRow } from '../../types.js';
import {
FLOW_STATS_SQL,
MAIN_SQL,
VehicleRepository,
WEEKLY_DETAIL_SQL,
WEEKLY_TRUCK_ID_SQLS,
} from './repository.js';
class FakeDatabase {
readonly calls: Array<{ sql: string; values?: any[] }> = [];
constructor(private readonly responses: any[][]) {}
async query<T = any[]>(sql: string, values?: any[]): Promise<[T, ...any[]]> {
this.calls.push({ sql, values });
return [this.responses.shift() as T];
}
}
function vehicle(id: string): Vehicle {
return { id } as Vehicle;
}
test('keeps all vehicle GET endpoints in their established order', () => {
const source = readFileSync(new URL('../vehicles.ts', import.meta.url), 'utf8');
const paths = Array.from(source.matchAll(/app\.get\('([^']+)'/g), (match) => match[1]);
assert.deepEqual(paths, [
'/summary',
'/by-type',
'/by-batch',
'/inventory-analysis',
'/dept-stats',
'/region-stats',
'/customer-stats',
'/list',
'/inventory-stats',
'/weekly-detail',
'/flow-stats',
'/subjects',
'/refresh',
'/debug',
'/region-chart',
]);
assert.doesNotMatch(source, /\bSELECT\b|pool\.query|MAIN_SQL/);
});
test('keeps the non-empty vehicle cache for less than 60 seconds', async () => {
let now = 100_000;
const db = new FakeDatabase([[{ id: 'first' }], [{ id: 'second' }]]);
const repository = new VehicleRepository(
db,
() => now,
(row: VehicleRow) => vehicle(row.id),
);
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
now += 59_999;
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
now += 1;
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, MAIN_SQL]);
});
test('keeps empty vehicle results uncached', async () => {
const db = new FakeDatabase([[], []]);
const repository = new VehicleRepository(db, () => 100_000);
await repository.getVehicles();
await repository.getVehicles();
assert.equal(db.calls.length, 2);
});
test('refresh invalidates vehicles but retains the weekly truck-id cache', async () => {
const db = new FakeDatabase([
[{ id: 'first' }],
[{ truck_id: 'pending' }],
[{ truck_id: 'delivered' }],
[{ truck_id: 'returned' }],
[{ truck_id: 'replaced' }, { truck_id: null }],
[{ id: 'second' }],
]);
const repository = new VehicleRepository(
db,
() => 100_000,
(row: VehicleRow) => vehicle(row.id),
);
await repository.getVehicles();
const weeklyBefore = await repository.getWeeklyTruckIds();
repository.invalidateRefreshCaches();
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
assert.equal(await repository.getWeeklyTruckIds(), weeklyBefore);
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, ...WEEKLY_TRUCK_ID_SQLS, MAIN_SQL]);
assert.deepEqual([...weeklyBefore.replaced], ['replaced']);
});
test('keeps weekly detail SQL selection and flow date parameters unchanged', async () => {
const db = new FakeDatabase([[{ truck_id: '1' }], [{ id: 'delivered-1' }]]);
const repository = new VehicleRepository(db);
assert.deepEqual(await repository.getWeeklyDetailRows('new'), [{ truck_id: '1' }]);
assert.deepEqual(await repository.getFlowDetailRows('2026-08-01', '2026-08-13'), [{ id: 'delivered-1' }]);
assert.deepEqual(db.calls, [
{ sql: WEEKLY_DETAIL_SQL.new, values: undefined },
{
sql: FLOW_STATS_SQL,
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
},
]);
});
+462
View File
@@ -0,0 +1,462 @@
import pool from '../../db.js';
import type { Vehicle, VehicleRow } from '../../types.js';
import { transformRow } from './model.js';
import type { WeeklyTruckIds } from './types.js';
interface Database {
query<T = any[]>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
}
type FlowType = 'delivered' | 'returned' | 'replaced';
export type WeeklyDetailType = FlowType | 'pending' | 'new';
export interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
weeklyRemoved: number;
weeklyDelivered: number;
weeklyReturned: number;
weeklyReplaced: number;
}
export const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id,
vi.plate_number AS 车牌号,
vi.vin AS vin,
vm.brand AS 车辆品牌,
vm.model AS 车辆型号,
vi.body_color AS 车辆颜色,
vi.rental_company AS 租赁公司,
CASE vi.vehicle_source
WHEN '0' THEN '自有'
WHEN '1' THEN '挂靠'
WHEN '2' THEN '外租'
WHEN '3' THEN '自有'
ELSE vi.actual_ownership
END AS 车辆归属状态Label,
vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
info.lat AS 纬度,
info.lng AS 经度,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number
AND info.is_deleted = 0
LEFT JOIN common_district info_province
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND info_province.STATUS = 'VALID'
LEFT JOIN common_district info_city
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND info_city.STATUS = 'VALID'
LEFT JOIN common_district vi_province
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
AND vi_province.STATUS = 'VALID'
LEFT JOIN common_district vi_city
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
AND vi_city.STATUS = 'VALID'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
// Week range: last Saturday 00:00 to this Friday 23:59
// MySQL: WEEKDAY() returns 0=Monday..6=Sunday, Saturday=5
// "上周六-本周五": offset from today to last Saturday
export const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
export const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
export const WEEKLY_TRUCK_ID_SQLS = [
`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM vehicle_status
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`,
`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM delivery_vehicle
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
AND delivery_status IN (2,3,5)`,
`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM return_vehicle_task
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
AND status IN (2,3,5)`,
`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
FROM vehicle_replacement
WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`,
] as const;
// 交车单 SQL
const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)`;
// 还车单 SQL
const RETURNED_SQL = `SELECT
r.id, DATE(r.arrival_time) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.arrival_time IS NOT NULL
AND r.status IN (2,3,5)`;
// 替换车单 SQL
const REPLACED_SQL = `SELECT
vr.id, DATE(vr.replace_time) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
c.contract_type,
c.customer_name
FROM vehicle_replacement vr
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.replace_time IS NOT NULL
AND vr.status = 20`;
export const WEEKLY_DETAIL_SQL: Record<WeeklyDetailType, string> = {
delivered: `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`,
returned: `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`,
replaced: `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`,
pending: `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`,
new: `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`,
};
export const FLOW_STATS_SQL = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const CACHE_TTL = 60 * 1000;
export class VehicleRepository {
private cachedVehicles: Vehicle[] = [];
private lastFetchTime = 0;
private cachedWeeklyTruckIds: WeeklyTruckIds | null = null;
private weeklyTruckIdsLastFetch = 0;
private cachedWeeklyStats: WeeklyStats | null = null;
private weeklyStatsLastFetch = 0;
constructor(
private readonly db: Database,
private readonly now: () => number = Date.now,
private readonly mapVehicleRow: (row: VehicleRow) => Vehicle = transformRow,
) {}
async getVehicles(): Promise<Vehicle[]> {
const now = this.now();
if (this.cachedVehicles.length > 0 && now - this.lastFetchTime < CACHE_TTL) {
return this.cachedVehicles;
}
const [rows] = await this.db.query<any[]>(MAIN_SQL);
this.cachedVehicles = (rows as VehicleRow[]).map(this.mapVehicleRow);
this.lastFetchTime = now;
return this.cachedVehicles;
}
async getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
const now = this.now();
if (this.cachedWeeklyTruckIds && now - this.weeklyTruckIdsLastFetch < CACHE_TTL) {
return this.cachedWeeklyTruckIds;
}
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all(
WEEKLY_TRUCK_ID_SQLS.map((sql) => this.db.query<any[]>(sql)),
);
const toSet = (rows: any[]) => new Set(rows.map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
this.cachedWeeklyTruckIds = {
pending: toSet(pendingRows as any[]),
delivered: toSet(deliveredRows as any[]),
returned: toSet(returnedRows as any[]),
replaced: toSet(replacedRows as any[]),
};
this.weeklyTruckIdsLastFetch = now;
return this.cachedWeeklyTruckIds;
}
async getWeeklyStats(): Promise<WeeklyStats> {
const now = this.now();
if (this.cachedWeeklyStats && now - this.weeklyStatsLastFetch < CACHE_TTL) {
return this.cachedWeeklyStats;
}
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
]);
this.cachedWeeklyStats = {
pendingDelivery: (pendingRows as any[])[0]?.cnt || 0,
weeklyNew: (newRows as any[])[0]?.cnt || 0,
weeklyRemoved: (removedRows as any[])[0]?.cnt || 0,
weeklyDelivered: (deliveredRows as any[])[0]?.cnt || 0,
weeklyReturned: (returnedRows as any[])[0]?.cnt || 0,
weeklyReplaced: (replacedRows as any[])[0]?.cnt || 0,
};
this.weeklyStatsLastFetch = now;
return this.cachedWeeklyStats;
}
invalidateRefreshCaches(): void {
this.lastFetchTime = 0;
this.weeklyStatsLastFetch = 0;
}
async getRealtimeMileageRows(): Promise<any[]> {
const [rows] = await this.db.query<any[]>(`
SELECT plate_number, day_mileage
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`);
return rows;
}
async getAttendanceMileageRows(): Promise<any[]> {
const [rows] = await this.db.query<any[]>(`SELECT plate_number, day_mileage FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
return rows;
}
async getWeeklyDetailRows(type: WeeklyDetailType): Promise<any[]> {
const [rows] = await this.db.query<any[]>(WEEKLY_DETAIL_SQL[type]);
return rows;
}
async getFlowDetailRows(start: string, end: string): Promise<FlowDetailRow[]> {
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end, start, end]);
return rows as FlowDetailRow[];
}
async getWeeklyDebugData(): Promise<{
dateRange: any;
deliveredAll: any;
deliveredRecent: any;
latestTake: any;
returnedRecent: any;
latestReturn: any;
}> {
const [[dateRange]] = await this.db.query<any[]>(`SELECT
${WEEK_START_SQL} AS week_start,
${WEEK_END_SQL} AS week_end,
CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[deliveredRecent]] = await this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
const [[latestTake]] = await this.db.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[returnedRecent]] = await this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[latestReturn]] = await this.db.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
return { dateRange, deliveredAll, deliveredRecent, latestTake, returnedRecent, latestReturn };
}
async getRealtimeProvinceRows(): Promise<any[]> {
const [rows] = await this.db.query<any[]>(`SELECT plate_number, province FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
return rows;
}
}
export const vehicleRepository = new VehicleRepository(pool as unknown as Database);
+26
View File
@@ -0,0 +1,26 @@
export type VehicleTypeCategory = 't4_5' | 't4_5c' | 't18' | 't49' | 'trailer' | 'other';
export type VehicleTypeCounts = Record<VehicleTypeCategory, number> & { total: number };
export interface WeeklyTruckIds {
pending: Set<string>;
delivered: Set<string>;
returned: Set<string>;
replaced: Set<string>;
}
export interface VehicleStats {
total: number;
inventory: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
weeklyDelivered: number;
weeklyReturned: number;
weeklyReplaced: number;
}
export interface DateRange {
start: string;
end: string;
}
+125
View File
@@ -0,0 +1,125 @@
import type { Vehicle } from '../../types.js';
import { REGIONS, classifyVehicleType } from './model.js';
import type { DateRange, VehicleStats, VehicleTypeCounts, WeeklyTruckIds } from './types.js';
export function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = {
t4_5: 0,
t4_5c: 0,
t18: 0,
t49: 0,
trailer: 0,
other: 0,
total: 0,
};
for (const vehicle of vehicles) {
counts[classifyVehicleType(vehicle)] += 1;
counts.total += 1;
}
return counts;
}
export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
return regions.reduce<Record<string, number>>((counts, region) => {
counts[region] = vehicles.filter((vehicle) => vehicle.location === region).length;
return counts;
}, {});
}
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
const ids = list.map((vehicle) => String(vehicle.id));
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory' || vehicle.status === 'Abnormal');
return {
total: list.length,
inventory: inventory.length,
inventoryRegions: getRegionCounts(inventory, REGIONS),
pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
weeklyDelivered: weeklyIds ? ids.filter((id) => weeklyIds.delivered.has(id)).length : 0,
weeklyReturned: weeklyIds ? ids.filter((id) => weeklyIds.returned.has(id)).length : 0,
weeklyReplaced: weeklyIds ? ids.filter((id) => weeklyIds.replaced.has(id)).length : 0,
};
}
export function getMileageStats(vehicles: Vehicle[], mileageByPlate: ReadonlyMap<string, number>): {
attendanceRate: number;
avgMileage: number;
} {
const active = vehicles.filter((vehicle) => (mileageByPlate.get(vehicle.plateNumber) || 0) > 0).length;
let totalMileage = 0;
let count = 0;
for (const vehicle of vehicles) {
const mileage = mileageByPlate.get(vehicle.plateNumber);
if (mileage !== undefined && mileage > 0) {
totalMileage += mileage;
count += 1;
}
}
return {
attendanceRate: vehicles.length > 0 ? Math.round((active / vehicles.length) * 1000) / 10 : 0,
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
};
}
const DEPARTMENT_NUMBERS: Record<string, number> = {
'一': 1,
'二': 2,
'三': 3,
'四': 4,
'五': 5,
'六': 6,
'七': 7,
'八': 8,
'九': 9,
'十': 10,
};
export function compareDepartmentNames(left: string, right: string): number {
const getOrder = (name: string): number => {
if (name === '公务车') return 100;
const match = name.match(/[一二三四五六七八九十]/);
return match ? (DEPARTMENT_NUMBERS[match[0]] || 99) : 99;
};
return getOrder(left) - getOrder(right);
}
export function formatDateOnly(value: Date): string {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
export function addDateDays(date: string, days: number): string {
const value = new Date(`${date}T00:00:00`);
value.setDate(value.getDate() + days);
return formatDateOnly(value);
}
export function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
export function normalizeDateRange(
startRaw: string | undefined,
endRaw: string | undefined,
now: Date = new Date(),
): DateRange {
const today = formatDateOnly(now);
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}