revert: restore v1.1.14 (37a9f303)
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
shishengliang
2026-09-16 09:29:30 +08:00
parent c49a02e3d9
commit 6ea1b3d5dd
210 changed files with 4448 additions and 36179 deletions
@@ -26,7 +26,7 @@ function vehicle(id: string): Vehicle {
}
test('keeps all vehicle GET endpoints in their established order', () => {
const source = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8');
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, [
+1 -1
View File
@@ -1,4 +1,4 @@
import pool from '../../db/mysql.js';
import pool from '../../db.js';
import type { Vehicle, VehicleRow } from '../../types.js';
import { transformRow } from './model.js';
import type { WeeklyTruckIds } from './types.js';
-671
View File
@@ -1,671 +0,0 @@
import { Hono } from 'hono';
import type {
Vehicle,
SummaryData,
TypeSummary,
ModelSummary,
BatchSummary,
BatchGroup,
InventoryTypeSummary,
} from '../../types.js';
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 './model.js';
import {
compareDepartmentNames,
countByType,
getMileageStats,
getStats,
listDateRange,
normalizeDateRange,
} from './utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './repository.js';
export { mapRegion } from './model.js';
const app = new Hono();
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
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);
return maskCustomerNames(list);
}
// 归属公司筛选(所属公司 = vehicle_info.registered_ownership, 即 Vehicle.subjectOrg)
function getSubjectParam(c: Context): string | null {
const raw = (c.req.query('subject') || '').trim();
return raw ? raw : null;
}
function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
const subject = getSubjectParam(c);
if (!subject) return vehicles;
return vehicles.filter((v) => (v.subjectOrg || '') === subject);
}
// GET /api/vehicles/summary
app.get('/summary', async (c) => {
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,
operating: {
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
hanging: 0,
},
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: 0,
weeklyRemoved: 0,
weeklyDelivered: [...weeklyIds.delivered].filter(id => vehicleIds.has(id)).length,
weeklyReturned: [...weeklyIds.returned].filter(id => vehicleIds.has(id)).length,
weeklyReplaced: [...weeklyIds.replaced].filter(id => vehicleIds.has(id)).length,
};
return c.json(summary);
});
// GET /api/vehicles/by-type
app.get('/by-type', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
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)));
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) => compareModelNames(a.model, b.model)),
};
});
return c.json(result);
});
// GET /api/vehicles/by-batch
app.get('/by-batch', async (c) => {
const vehicles = await getVehiclesForUser(c);
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 = applySubjectFilter(c, await vehicleRepository.getVehicles());
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)));
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 getVehiclesForUser(c);
const withManager = vehicles.filter((v) => v.status === 'Operating');
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
const realtimeRows = await vehicleRepository.getRealtimeMileageRows();
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 EXCLUDED_MANAGERS = new Set(['超级用户', '刘思宇', '潘舒', '黄卓华', '许铮杰']);
const deptMap = new Map<string, Map<string, Vehicle[]>>();
for (const v of withManager) {
const isPublicServiceVehicle = v.model === '公务车/挂靠车';
const dept = isPublicServiceVehicle ? '公务车' : (v.departmentName || '未分配部门');
const mgr = v.customerManager || '未分配';
if (EXCLUDED_MANAGERS.has(mgr)) continue;
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);
}
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
const allDeptVehicles = Array.from(mgrMap.values()).flat();
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);
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) => compareDepartmentNames(a.department, 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 getVehiclesForUser(c);
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[]) => {
const KNOWN = ['4.5T', '18T', '49T'] as const;
const make = (label: string, tv: Vehicle[]) => ({
type: label,
total: tv.length,
operating: tv.filter((v) => v.status === 'Operating').length,
inventory: tv.filter((v) => v.status === 'Inventory').length,
pending: tv.filter((v) => v.status === 'Pending').length,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
});
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
const other = vList.filter((v) => !KNOWN.includes(v.type as typeof KNOWN[number]));
if (other.length > 0) known.push(make('其他', other));
return known.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 getVehiclesForUser(c);
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);
});
// GET /api/vehicles/list — flat list with optional filters
app.get('/list', async (c) => {
const vehicles = await getVehiclesForUser(c);
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 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');
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') {
filtered = filterByLocation(filtered, location, c.req.query('source'));
}
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');
} else if (category === 'Pending') {
filtered = filtered.filter((v) => v.status === 'Pending');
}
}
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) => {
if (department === '公务车') return v.model === '公务车/挂靠车';
if (department === '未分配部门') return v.model !== '公务车/挂靠车' && !v.departmentName;
return 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,
rentCompany: v.rentCompany,
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 — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => {
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> = {
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
// Optional filters: model, batch, location, source — 按缓存车辆集合的 truck_id 交集过滤
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');
const validTypes: WeeklyDetailType[] = ['delivered', 'returned', 'replaced', 'pending', 'new'];
if (!validTypes.includes(type as WeeklyDetailType)) {
return c.json([]);
}
let result = await vehicleRepository.getWeeklyDetailRows(type as WeeklyDetailType);
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
const hasModelFilter = model && model !== 'All';
const hasBatchFilter = batch && batch !== 'All';
const hasLocationFilter = location && location !== 'All';
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
const vehicles = await getVehiclesForUser(c);
let pool2 = vehicles;
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
if (hasBatchFilter) pool2 = pool2.filter((v) => (v.contractNo || '未知') === batch);
if (hasLocationFilter) pool2 = filterByLocation(pool2, location, source);
const truckSet = new Set(pool2.map((v) => String(v.id)));
result = result.filter((r: any) => truckSet.has(String(r.truck_id)));
}
const masked = result.map(r => ({ ...r, customer_name: maskCustomerName(r.customer_name) }));
return c.json(masked);
});
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
const allowedTruckIds = new Set(allowedVehicles.map((v) => String(v.id)));
const rows = await vehicleRepository.getFlowDetailRows(start, end);
const details = (rows as FlowDetailRow[])
.filter((row) => allowedTruckIds.has(String(row.truck_id)))
.map((row) => ({
id: row.id,
type: row.type,
typeLabel: row.type_label,
date: row.stat_date,
truckId: row.truck_id,
plateNumber: row.plate_number,
eventTime: row.event_time,
submitTime: row.submit_time,
department: row.department || '',
manager: row.manager || '',
customerName: maskCustomerName(row.customer_name),
}));
const dailyMap = new Map<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
for (const date of listDateRange(start, end)) dailyMap.set(date, { date, delivered: 0, returned: 0, replaced: 0, total: 0 });
for (const item of details) {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
stat.total += 1;
}
const daily = Array.from(dailyMap.values());
const totals = daily.reduce(
(acc, item) => ({
delivered: acc.delivered + item.delivered,
returned: acc.returned + item.returned,
replaced: acc.replaced + item.replaced,
total: acc.total + item.total,
}),
{ delivered: 0, returned: 0, replaced: 0, total: 0 },
);
return c.json({ start, end, daily, totals, details });
});
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
app.get('/subjects', async (c) => {
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;
const map = new Map<string, { total: number; inventory: number; operating: number }>();
for (const v of visible) {
const name = (v.subjectOrg || '').trim();
if (!name) continue;
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
const s = map.get(name)!;
s.total += 1;
if (v.status === 'Inventory' || v.status === 'Abnormal') s.inventory += 1;
if (v.status === 'Operating') s.operating += 1;
}
const result = Array.from(map.entries())
.map(([name, stats]) => ({ name, ...stats }))
.sort((a, b) => b.total - a.total);
return c.json(result);
});
// GET /api/vehicles/refresh — force cache refresh
app.get('/refresh', async (c) => {
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, deliveredAll, deliveredRecent, latestTake, returnedRecent, latestReturn } = await vehicleRepository.getWeeklyDebugData();
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 getVehiclesForUser(c);
const operating = vehicles.filter((v) => v.status === 'Operating');
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'
const top = Number(c.req.query('top')) || 8;
let counts: Map<string, number>;
if (groupBy === 'province') {
counts = new Map<string, number>();
if (source === 'vehicle') {
// Use vehicle table's own province field
for (const v of operating) {
const prov = (v.province || '').replace(/省|市$/, '').trim() || '未知';
counts.set(prov, (counts.get(prov) || 0) + 1);
}
} else {
// Use realtime table province
const rows = await vehicleRepository.getRealtimeProvinceRows();
const plateProvince = new Map<string, string>();
for (const row of rows as any[]) {
const plate = (row.plate_number || '').trim();
const prov = (row.province || '').replace(/省|市$/, '').trim();
if (plate && prov) plateProvince.set(plate, prov);
}
for (const v of operating) {
const prov = plateProvince.get(v.plateNumber) || '未知';
counts.set(prov, (counts.get(prov) || 0) + 1);
}
}
} else {
counts = new Map<string, number>();
for (const v of operating) {
const key = mapMacroRegion(v.province, v.city);
counts.set(key, (counts.get(key) || 0) + 1);
}
}
// 分离"其他"和"未知",对剩余排序取 Top N,其余全部合入"其他"
const otherCount = (counts.get('其他') || 0) + (counts.get('未知') || 0);
counts.delete('其他');
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;