diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index ba9e530..507bfad 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -110,6 +110,40 @@ function mapInventoryRegion(region: string): string { 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' { @@ -605,12 +639,121 @@ app.get('/inventory-analysis', async (c) => { return c.json(result); }); +// GET /api/vehicles/dept-stats — department & manager breakdown +app.get('/dept-stats', async (c) => { + const vehicles = await getVehicles(); + const withManager = vehicles.filter((v) => v.customerManager); + + 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); + } + + const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => { + const allDeptVehicles = Array.from(mgrMap.values()).flat(); + 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) => v.status === 'Operating').length, + idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length, + managers, + }; + }).sort((a, b) => b.totalAssets - a.totalAssets); + + return c.json(result); +}); + +// GET /api/vehicles/region-stats — macro-region breakdown for operating vehicles +app.get('/region-stats', async (c) => { + const vehicles = await getVehicles(); + const operating = vehicles.filter((v) => v.status === 'Operating'); + + const regionMap = new Map(); + for (const v of operating) { + const region = mapMacroRegion(v.province, v.city); + if (!regionMap.has(region)) regionMap.set(region, []); + regionMap.get(region)!.push(v); + } + + const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他']; + const result = regionOrder + .filter((r) => regionMap.has(r)) + .map((region) => { + const rv = regionMap.get(region)!; + const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[]; + const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => { + const typeVehicles = rv.filter((v) => v.type === type); + return { + type, + total: typeVehicles.length, + operating: typeVehicles.filter((v) => v.status === 'Operating').length, + inventory: typeVehicles.filter((v) => v.status === 'Inventory').length, + customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[], + }; + }).filter((t) => t.total > 0); + + return { region, totalAssets: rv.length, operatingCount: rv.filter((v) => v.status === 'Operating').length, inventoryCount: rv.filter((v) => v.status === 'Inventory').length, customers, typeBreakdown }; + }); + + 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' && v.customerName); + + 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) => !['4.5T', '18T', '49T'].includes(v.type), +}; + // 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(); + const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer } = c.req.query(); let filtered = vehicles; + if (vehicleType && VEHICLE_TYPE_FILTERS[vehicleType]) { + filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]); + } if (batch && batch !== 'All') { filtered = filtered.filter((v) => (v.contractNo || '未知') === batch); } @@ -633,6 +776,20 @@ app.get('/list', async (c) => { filtered = filtered.filter((v) => v.status === 'Operating'); } } + if (manager) { + filtered = filtered.filter((v) => v.customerManager === manager); + } + if (customer) { + filtered = filtered.filter((v) => v.customerName === customer); + } + 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) => ({