Files
ln-bi/src/server/routes/vehicles.ts
kkfluous 0cc5024132
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
feat: 羚牛 BI 报表服务初始版本
- Hono + TypeScript 后端,连接 MySQL 数据库
- React + Vite + Tailwind 前端
- 车辆资产实时汇总(按车型/品牌型号分组)
- 本周交车/还车/替换统计(关联业务单据)
- 车牌号详情弹窗
- Dockerfile + Woodpecker CI 流水线

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:02:49 +08:00

614 lines
25 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
truck.id 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 '其它';
}
// Map rental status to frontend status
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Abnormal' {
if (!rentStatus) return 'Inventory';
const s = rentStatus.trim();
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
if (s === '在库' || s === '待交车') return 'Inventory';
if (s === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership status
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
function mapOwnership(ascriptionLabel: string | null): string {
if (!ascriptionLabel) return 'Self';
const s = ascriptionLabel.trim();
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '挂靠') return 'Hanging';
return 'Self';
}
// 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,
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>);
}
function getStats(list: Vehicle[]) {
return {
total: list.length,
inventory: list.filter((v) => v.status === 'Inventory').length,
inventoryRegions: getRegionCounts(
list.filter((v) => v.status === 'Inventory'),
REGIONS,
),
pending: 0,
operating: list.filter((v) => v.status === 'Operating').length,
weeklyDelivered: 0,
weeklyReturned: 0,
weeklyReplaced: 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 (${DELIVERED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${RETURNED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${REPLACED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.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').length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
},
...weekly,
};
return c.json(summary);
});
// GET /api/vehicles/by-type
app.get('/by-type', 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: 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);
// Use contractNo as batch identifier
const batches = Array.from(new Set(modelVehicles.map((v) => v.contractNo || '未知'))).filter(Boolean);
return {
model,
...getStats(modelVehicles),
batches: batches.map((batch) => ({
batch,
...getStats(modelVehicles.filter((v) => (v.contractNo || '未知') === batch)),
})),
};
});
const typeStats = getStats(typeVehicles);
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/list — flat list with optional filters
app.get('/list', async (c) => {
const vehicles = await getVehicles();
const { batch, model, location, status, category } = c.req.query();
let filtered = vehicles;
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 both display region names and inventory region names
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
filtered = filtered.filter((v) => v.location === mappedLocation);
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
}
if (category) {
if (category === 'Inventory') {
filtered = filtered.filter((v) => v.status === 'Inventory');
} else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating');
}
}
return c.json(
filtered.map((v) => ({
id: v.id,
plateNumber: v.plateNumber,
type: v.type,
model: v.model,
location: v.location,
status: v.status,
ownership: v.ownership,
contractNo: v.contractNo,
customerName: v.customerName,
subjectOrg: v.subjectOrg,
})),
);
});
// 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 {
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;
const vehicles = await getVehicles();
return c.json({ message: 'Cache refreshed', count: vehicles.length });
});
export default app;