refactor: modularize application domains
This commit is contained in:
@@ -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`;
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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), ['业务一部', '业务二部', '业务十部', '未分配部门', '公务车']);
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user