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 './vehicles/model.js'; import { compareDepartmentNames, countByType, getMileageStats, getStats, listDateRange, normalizeDateRange, } from './vehicles/utils.js'; import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js'; export { mapRegion } from './vehicles/model.js'; const app = new Hono(); async function getVehiclesForUser(c: Context): Promise { 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, ), }; }); 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 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(); 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>(); 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>(); 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(); 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(); 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 = { 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 // 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(); 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(); 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; if (groupBy === 'province') { counts = new Map(); 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(); 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(); 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;