Files
ln-bi/src/server/routes/vehicles.ts
kkfluous 16f5ef8741
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: 区域统计客户筛选生效,后端region-stats支持过滤参数
- 后端/region-stats新增customer/city/region查询参数
- 前端regionFilters变化时重新请求后端数据
- 移除前端冗余过滤逻辑,由后端统一处理

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 00:42:59 +08:00

1061 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono';
import pool from '../db.js';
import type {
VehicleRow,
Vehicle,
SummaryData,
TypeSummary,
ModelSummary,
BatchSummary,
BatchGroup,
InventoryTypeSummary,
} from '../types.js';
const app = new Hono();
const MAIN_SQL = `SELECT
CAST(truck.id AS CHAR) AS id,
truck.plate_number AS 车牌号,
truck.vin AS vin,
truck.brand AS 车辆品牌,
truck.model AS 车辆型号,
truck.color AS 车辆颜色,
truck.rent_from_company AS 租赁公司,
dic_ascription_status.dic_name AS 车辆归属状态Label,
dic_type.dic_name AS 车辆型号Label,
truck.stock_area AS 库存区域,
truck.truck_rent_status AS 车辆租赁状态,
dic_status.dic_name AS 车辆租赁状态Label,
truck.is_operation AS 是否营运,
info.province AS 省,
info.city AS 市,
info.lat AS 纬度,
info.lng AS 经度,
dic_brand.dic_name AS 车辆品牌Label,
si.contract_id AS 合同ID,
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
cus.customer_name AS 客户名称,
org.org_name AS 合同归属公司,
dep.dep_name AS 合同归属部门,
org_truck.org_name AS 主体,
c.project_name AS 项目名称,
u.user_name AS 客户经理
FROM tab_truck truck
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.id = truck.id
LEFT JOIN tab_dic dic_type
ON dic_type.parent_code = 'dic_truck_type'
AND dic_type.dic_code = truck.model
AND dic_type.is_deleted = 0
LEFT JOIN tab_dic dic_status
ON dic_status.parent_code = 'dic_truck_rent_status'
AND dic_status.dic_code = truck.truck_rent_status
AND dic_status.is_deleted = 0
LEFT JOIN tab_dic dic_brand
ON dic_brand.parent_code = 'dic_vehicle_brand'
AND dic_brand.dic_code = truck.brand
AND dic_brand.is_deleted = 0
LEFT JOIN tab_truck_status_info si
ON si.truck_id = truck.id
AND si.is_deleted = 0
LEFT JOIN tab_contract c
ON c.id = si.contract_id
AND c.is_deleted = 0
LEFT JOIN tab_customer cus
ON cus.id = c.customer_id
AND cus.is_deleted = 0
LEFT JOIN tab_org org
ON org.id = c.org_id
AND org.is_deleted = 0
LEFT JOIN tab_org org_truck
ON org_truck.id = truck.org_id
AND org_truck.is_deleted = 0
LEFT JOIN tab_dic dic_ascription_status
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
AND dic_ascription_status.dic_code = truck.ascription_status
AND dic_ascription_status.is_deleted = 0
LEFT JOIN tab_user u
ON u.id = c.bd
AND u.is_deleted = 0
LEFT JOIN tab_department dep
ON dep.id = u.dep_id
AND dep.is_deleted = 0
WHERE truck.is_deleted = 0
AND truck.is_operation = 1`;
// Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
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 rental status to frontend status
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
if (!rentStatus) return 'Inventory';
const s = rentStatus.trim();
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
if (s === '在库') return 'Inventory';
if (s === '待交车') return 'Pending';
if (s === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership from truck_rent_status (rentStatusLabel)
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
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 vehicle type category from model label
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
const label = (modelLabel || '').trim();
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 (label.includes('叉车')) return '叉车';
if (label.includes('半挂车')) return '挂车';
return '其他车型';
}
// 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.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 },
// 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰嘉氢', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰浙氢-蓝白绿)', order: 405 },
'楚风-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 },
};
function deriveModelTag(
brandLabel: string | null,
modelLabel: string | null,
color: string | null,
ownershipLabel: string | null,
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (modelLabel || '').trim();
const c = (color || '').trim();
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.Label),
model: deriveModelTag(row.Label, row.Label, row., row.Label, row.),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.Label),
ownership: mapOwnership(row.Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
orgName: row.合同归属公司,
departmentName: row.合同归属部门,
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
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;
}
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(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
]);
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)`;
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
weeklyRemoved: number;
weeklyDelivered: number;
weeklyReturned: number;
weeklyReplaced: number;
}
// 交车单 SQL
const DELIVERED_SQL = `SELECT
take.id, DATE(take.handover_date) AS handover_date,
truck.id AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
// 还车单 SQL
const RETURNED_SQL = `SELECT
r.id, DATE(r.return_date) AS handover_date,
truck.id AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
// 替换车单 SQL
const REPLACED_SQL = `SELECT
take.id, DATE(take.handover_date) AS handover_date,
truck.id AS truck_id, truck.plate_number,
dic_contract_type.dic_name AS contract_type,
customer.customer_name
FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
LEFT JOIN tab_dic dic_contract_type
ON dic_contract_type.parent_code = 'dic_contract_type'
AND dic_contract_type.dic_code = contract.contract_type
AND dic_contract_type.is_deleted = 0
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
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 tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${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, weekly] = await Promise.all([getVehicles(), getWeeklyStats()]);
const summary: SummaryData = {
totalAssets: vehicles.length,
operating: {
total: vehicles.filter((v) => v.status === 'Operating').length,
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
},
inventory: {
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
},
pendingDelivery: vehicles.filter((v) => v.status === 'Pending').length,
weeklyNew: weekly.weeklyNew,
weeklyRemoved: weekly.weeklyRemoved,
weeklyDelivered: weekly.weeklyDelivered,
weeklyReturned: weekly.weeklyReturned,
weeklyReplaced: weekly.weeklyReplaced,
};
return c.json(summary);
});
// GET /api/vehicles/by-type
app.get('/by-type', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehicles(), 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 typeVehicles = vehicles.filter(t.filter);
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
const modelSummaries: ModelSummary[] = models.map((model) => {
const modelVehicles = typeVehicles.filter((v) => v.model === model);
const batches = Array.from(new Set(modelVehicles.map((v) => v.contractNo || '未知'))).filter(Boolean);
return {
model,
...getStats(modelVehicles, weeklyIds),
batches: batches.map((batch) => ({
batch,
...getStats(modelVehicles.filter((v) => (v.contractNo || '未知') === batch), weeklyIds),
})),
};
});
const typeStats = getStats(typeVehicles, weeklyIds);
return {
type: t.name,
totalAssets: typeVehicles.length,
totalInventory: typeStats.inventory,
totalOperating: typeStats.operating,
inventoryRegions: typeStats.inventoryRegions,
pending: typeStats.pending,
weeklyDelivered: typeStats.weeklyDelivered,
weeklyReturned: typeStats.weeklyReturned,
weeklyReplaced: typeStats.weeklyReplaced,
models: modelSummaries.sort((a, b) => getModelOrder(a.model) - getModelOrder(b.model)),
};
});
return c.json(result);
});
// GET /api/vehicles/by-batch
app.get('/by-batch', async (c) => {
const vehicles = await getVehicles();
const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知')))
.filter(Boolean)
.sort()
.reverse();
const result: BatchGroup[] = batches.map((batch) => {
const batchVehicles = vehicles.filter((v) => (v.contractNo || '未知') === batch);
const models = Array.from(new Set(batchVehicles.map((v) => v.model)));
return {
batch,
...getStats(batchVehicles),
models: models.map((model) => {
const modelVehicles = batchVehicles.filter((v) => v.model === model);
return {
model,
type: modelVehicles[0]?.type || '',
...getStats(modelVehicles),
};
}),
};
});
return c.json(result);
});
// GET /api/vehicles/inventory-analysis
app.get('/inventory-analysis', async (c) => {
const vehicles = await 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 typeVehicles = vehicles.filter(t.filter);
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
const modelData = models.map((model) => {
const modelVehicles = typeVehicles.filter((v) => v.model === model);
const inventoryVehicles = modelVehicles.filter((v) => v.status === 'Inventory');
return {
model,
totalAssets: modelVehicles.length,
totalInventory: inventoryVehicles.length,
regions: INVENTORY_REGIONS.reduce(
(acc, reg) => {
acc[reg] = inventoryVehicles.filter((v) => mapInventoryRegion(v.location) === reg).length;
return acc;
},
{} as Record<string, number>,
),
};
});
const typeInventory = typeVehicles.filter((v) => v.status === 'Inventory');
return {
type: t.name,
totalAssets: typeVehicles.length,
totalInventory: typeInventory.length,
models: modelData,
regionSubtotals: INVENTORY_REGIONS.reduce(
(acc, reg) => {
acc[reg] = typeInventory.filter((v) => mapInventoryRegion(v.location) === reg).length;
return acc;
},
{} as Record<string, number>,
),
};
});
return c.json(result);
});
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
app.get('/dept-stats', async (c) => {
const vehicles = await getVehicles();
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 todayMileageMap = new Map<string, number>();
for (const row of realtimeRows as any[]) {
const plate = (row.plate_number || '').trim();
if (plate) todayMileageMap.set(plate, Number(row.day_mileage) || 0);
}
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const dept = v.departmentName || '公务车';
const mgr = v.customerManager || '未分配';
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
const mgrMap = deptMap.get(dept)!;
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
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 managers = Array.from(mgrMap.entries())
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
.sort((a, b) => b.total - a.total);
return {
department,
totalAssets: allDeptVehicles.length,
operatingCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length,
idleCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) === 0).length,
attendanceRate: deptMileage.attendanceRate,
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);
});
return c.json(result);
});
// GET /api/vehicles/region-stats — macro-region with city drill-down
app.get('/region-stats', async (c) => {
const vehicles = await getVehicles();
const { customer, city: filterCity, region: filterRegion } = c.req.query();
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
if (customer) operating = operating.filter((v) => v.customerName === customer);
if (filterCity) operating = operating.filter((v) => resolveCity(v.city, v.province) === filterCity);
if (filterRegion) operating = operating.filter((v) => mapMacroRegion(v.province, v.city) === filterRegion);
const regionCityMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of operating) {
const region = mapMacroRegion(v.province, v.city);
const city = resolveCity(v.city, v.province);
if (!regionCityMap.has(region)) regionCityMap.set(region, new Map());
const cityMap = regionCityMap.get(region)!;
if (!cityMap.has(city)) cityMap.set(city, []);
cityMap.get(city)!.push(v);
}
const getTypeBreakdown = (vList: Vehicle[]) =>
['4.5T', '18T', '49T'].map((type) => {
const tv = vList.filter((v) => v.type === type);
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
}).filter((t) => t.total > 0);
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
const result = regionOrder
.filter((r) => regionCityMap.has(r))
.map((region) => {
const cityMap = regionCityMap.get(region)!;
const allVehicles = Array.from(cityMap.values()).flat();
const customers = Array.from(new Set(allVehicles.map((v) => v.customerName).filter(Boolean))) as string[];
const allCities = Array.from(cityMap.entries())
.map(([city, cv]) => ({
city,
totalAssets: cv.length,
operatingCount: cv.filter((v) => v.status === 'Operating').length,
pendingCount: cv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(cv),
}))
.sort((a, b) => b.totalAssets - a.totalAssets);
// Top 8 cities + merge rest into "其他"
const topCities = allCities.slice(0, 8);
const restCities = allCities.slice(8);
if (restCities.length > 0) {
const restVehicles = restCities.flatMap((c) => {
const key = c.city;
return cityMap.get(key) || [];
});
topCities.push({
city: '其他',
totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0),
operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0),
pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0),
customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
typeBreakdown: getTypeBreakdown(restVehicles),
});
}
const cities = topCities;
return {
region,
totalAssets: allVehicles.length,
operatingCount: allVehicles.filter((v) => v.status === 'Operating').length,
pendingCount: allVehicles.filter((v) => v.status === 'Pending').length,
customers,
typeBreakdown: getTypeBreakdown(allVehicles),
cities,
};
});
return c.json(result);
});
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
app.get('/customer-stats', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const custMap = new Map<string, Vehicle[]>();
for (const v of operating) {
const cust = v.customerName || '未分配客户';
if (!custMap.has(cust)) custMap.set(cust, []);
custMap.get(cust)!.push(v);
}
const result = Array.from(custMap.entries())
.map(([customer, cvs]) => {
const first = cvs[0];
return {
customer,
manager: first.customerManager || '',
brand: first.brandLabel || '',
department: first.departmentName || '',
region: mapMacroRegion(first.province, first.city),
city: first.city || '',
...countByType(cvs),
};
})
.sort((a, b) => b.total - a.total);
return c.json(result);
});
// 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 getVehicles();
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
let filtered = vehicles;
// 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 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');
if (attendance === 'active') {
filtered = filtered.filter((v) => (todayMap.get(v.plateNumber) || 0) > 0);
} else {
filtered = filtered.filter((v) => (todayMap.get(v.plateNumber) || 0) === 0);
}
}
if (vehicleType) {
if (VEHICLE_TYPE_FILTERS[vehicleType]) {
filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]);
} else if (vehicleType === '4.5T') {
filtered = filtered.filter((v) => v.type === '4.5T');
} else {
filtered = filtered.filter((v) => v.type === vehicleType);
}
}
if (batch && batch !== 'All') {
filtered = filtered.filter((v) => (v.contractNo || '未知') === batch);
}
if (model && model !== 'All') {
filtered = filtered.filter((v) => v.model === model);
}
if (location && location !== 'All') {
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
if (macroRegions.includes(location) || location === '其他') {
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
} else {
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
}
if (category) {
if (category === 'Inventory') {
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
} else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating');
}
}
if (manager) {
filtered = filtered.filter((v) => manager === '未分配' ? !v.customerManager : v.customerManager === manager);
}
if (customer) {
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
}
if (department) {
filtered = filtered.filter((v) => department === '公务车' ? !v.departmentName : v.departmentName === department);
}
if (isColdChain !== undefined) {
const wantCold = isColdChain === 'true';
filtered = filtered.filter((v) => wantCold ? v.model.includes('冷链') : !v.model.includes('冷链'));
}
if (isTrailer !== undefined) {
const wantTrailer = isTrailer === 'true';
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
return c.json(
filtered.map((v) => ({
id: v.id,
plateNumber: v.plateNumber,
vin: v.vin,
type: v.type,
model: v.model,
location: v.location,
province: v.province,
city: v.city,
status: v.status,
ownership: v.ownership,
contractNo: v.contractNo,
customerName: v.customerName,
subjectOrg: v.subjectOrg,
departmentName: v.departmentName,
customerManager: v.customerManager,
brandLabel: v.brandLabel,
orgName: v.orgName,
})),
);
});
// GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section
app.get('/inventory-stats', async (c) => {
const vehicles = await getVehicles();
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货',
t4_5c: '4.5T冷链',
t18: '18T',
t49: '49T',
trailer: '挂车',
other: '其他',
};
const groups = new Map<string, number>();
for (const v of inventory) {
const typeCategory = classifyVehicleType(v);
const typeName = TYPE_NAME_MAP[typeCategory];
const region = mapMacroRegion(v.province, v.city);
const city = resolveCity(v.city, v.province);
const brand = v.brandLabel || '未知';
const model = v.model;
const key = `${region}|${city}|${brand}|${typeName}|${model}`;
groups.set(key, (groups.get(key) || 0) + 1);
}
const result = Array.from(groups.entries())
.map(([key, quantity]) => {
const [region, city, brand, type, model] = key.split('|');
return { region, city, brand, type, model, batch: model, quantity };
})
.sort((a, b) => b.quantity - a.quantity);
return c.json(result);
});
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
app.get('/weekly-detail', async (c) => {
const type = c.req.query('type');
let sql: string;
if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
} else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
} else if (type === 'pending') {
sql = `SELECT truck.id AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
} else if (type === 'new') {
sql = `SELECT truck.id AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
} else {
return c.json([]);
}
const [rows] = await pool.query<any[]>(sql);
return c.json(rows);
});
// GET /api/vehicles/refresh — force cache refresh
app.get('/refresh', async (c) => {
lastFetchTime = 0;
weeklyStatsLastFetch = 0;
const vehicles = await getVehicles();
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 tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
return c.json({
weekRange: dateRange,
delivered: { total: deliveredAll[0]?.cnt, thisWeek: deliveredRecent[0]?.cnt, latestDate: latestTake[0]?.latest },
returned: { thisWeek: returnedRecent[0]?.cnt, latestDate: latestReturn[0]?.latest },
});
});
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
app.get('/region-chart', async (c) => {
const vehicles = await getVehicles();
const operating = vehicles.filter((v) => v.status === 'Operating');
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
const top = Number(c.req.query('top')) || 8;
const counts = new Map<string, number>();
for (const v of operating) {
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
counts.set(key, (counts.get(key) || 0) + 1);
}
// 分离"其他",对非"其他"排序取 Top N剩余全部合入"其他"
const otherCount = counts.get('其他') || 0;
counts.delete('其他');
const sorted = Array.from(counts.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
const result = sorted.slice(0, top);
const restTotal = sorted.slice(top).reduce((s, item) => s + item.value, 0) + otherCount;
if (restTotal > 0) result.push({ name: '其他', value: restTotal });
return c.json(result);
});
export default app;