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 { 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 = { // 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 { const now = Date.now(); if (cachedVehicles.length > 0 && now - lastFetchTime < CACHE_TTL) { return cachedVehicles; } const [rows] = await pool.query(MAIN_SQL); cachedVehicles = (rows as VehicleRow[]).map(transformRow); lastFetchTime = now; return cachedVehicles; } function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record { return regions.reduce((acc, reg) => { acc[reg] = vehicles.filter((v) => v.location === reg).length; return acc; }, {} as Record); } // Weekly truck ID sets, cached interface WeeklyTruckIds { pending: Set; delivered: Set; returned: Set; replaced: Set; } let cachedWeeklyTruckIds: WeeklyTruckIds | null = null; let weeklyTruckIdsLastFetch = 0; async function getWeeklyTruckIds(): Promise { const now = Date.now(); if (cachedWeeklyTruckIds && now - weeklyTruckIdsLastFetch < CACHE_TTL) { return cachedWeeklyTruckIds; } const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ pool.query(`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(`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(`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(`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 { const now = Date.now(); if (cachedWeeklyStats && now - weeklyStatsLastFetch < CACHE_TTL) { return cachedWeeklyStats; } const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([ pool.query(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`), pool.query(`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(`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(`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(`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(`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, ), }; }); 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, ), }; }); 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(` 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(); 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>(); 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 = { '一': 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 operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending'); const regionCityMap = new Map>(); 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(); 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 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(`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(); 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 = { '江浙沪': '嘉兴', '其它': '其他' }; 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 = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他', }; const groups = new Map(); 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(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(`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(`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(`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(`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(`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(`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(); 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;