import { inventoryKey, inventoryOperation, inventoryColor, emptyInventoryCounts, type InventoryCounts } from '../../shared/assets/inventory.js'; import { Hono } from 'hono'; import type { Vehicle, SummaryData, TypeSummary, ModelSummary, BatchSummary, BatchGroup, InventoryTypeSummary, } from '../types.js'; import { filterAssetVehiclesByPermission, filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js'; import type { AuthUser } from '../auth/types.js'; import type { Context } from 'hono'; import { INVENTORY_REGIONS, isOperatingVehicle, isInventoryVehicle, isInventoryAbnormal, isInventoryPending, 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'; import type { AssetAnalysisMetric, AssetAnalysisScope } from '../../shared/assets/analytics.js'; import { abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, loadMovementApprovalTimes, matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric, validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope, } from './vehicles/analytics.js'; export { mapRegion } from './vehicles/model.js'; const app = new Hono<{ Variables: { user: AuthUser } }>(); async function getVehiclesForUser(c: Context, includePublicInventory = true): Promise { const all = await vehicleRepository.getVehicles(); const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined; let list = user ? (includePublicInventory ? filterAssetVehiclesByPermission(all, 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 user = c.get('user') as AuthUser | undefined; const vehicleIds = new Set((user ? filterByPermission(vehicles, user) : vehicles).map(v => String(v.id))); const summary: SummaryData = { totalAssets: vehicles.length, operating: { total: vehicles.filter((v) => isOperatingVehicle(v)).length, self: vehicles.filter((v) => ['2', '自营'].includes(v.operationStatus || '')).length, leased: vehicles.filter((v) => ['1', '租赁'].includes(v.operationStatus || '')).length, public: vehicles.filter((v) => isOperatingVehicle(v) && v.ownership === 'Public').length, hanging: 0, }, inventory: { total: vehicles.filter((v) => isInventoryVehicle(v)).length, inStock: vehicles.filter((v) => isInventoryVehicle(v) && v.status === 'Inventory').length, abnormal: vehicles.filter((v) => isInventoryAbnormal(v)).length, }, pendingDelivery: vehicles.filter((v) => isInventoryPending(v)).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, allWeeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]); const user = c.get('user') as AuthUser | undefined; const historyIds = new Set((user ? filterByPermission(vehicles, user) : vehicles).map(v => String(v.id))); const weeklyIds = { ...allWeeklyIds, delivered: new Set([...allWeeklyIds.delivered].filter(id => historyIds.has(id))), returned: new Set([...allWeeklyIds.returned].filter(id => historyIds.has(id))), replaced: new Set([...allWeeklyIds.replaced].filter(id => historyIds.has(id))), }; 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, totalAbnormal: typeStats.abnormal, 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) => isInventoryVehicle(v)); 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) => isInventoryVehicle(v)); 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) => isOperatingVehicle(v)); // 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) => isOperatingVehicle(v) || isInventoryPending(v)); 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) => isOperatingVehicle(v)).length, inventory: tv.filter((v) => isInventoryVehicle(v)).length, abnormal: tv.filter((v) => isInventoryAbnormal(v)).length, pending: tv.filter((v) => isInventoryPending(v)).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) => isOperatingVehicle(v)).length, pendingCount: cv.filter((v) => isInventoryPending(v)).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); // The UI uses collapsible city sections, so keep actual cities addressable for drill-down. const cities = allCities; return { region, totalAssets: allVehicles.length, operatingCount: allVehicles.filter((v) => isOperatingVehicle(v)).length, pendingCount: allVehicles.filter((v) => isInventoryPending(v)).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) => isOperatingVehicle(v)); 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); }); /** Analysis uses the same permission-filtered collection and exact entry scope as details. */ app.on(['GET', 'POST'], '/analysis', async (c) => { const body = c.req.method === 'POST' ? await c.req.json().catch(() => null) : {}; if (!body || typeof body !== 'object' || Array.isArray(body)) return c.json({ error: '统计范围无效' }, 400); const scope = body.scope || {}; if (!validAnalysisScope(scope) || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) { return c.json({ error: '统计范围无效' }, 400); } let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), scope); if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys }); const approvals = await loadMovementApprovalTimes(vehicles); return c.json(buildAssetAnalysis(vehicles, approvals, Date.now())); }); app.post('/analysis/details', async (c) => { const body = await c.req.json().catch(() => null); const filterValues = body?.filterValues === undefined ? {} : body.filterValues; const metrics: AssetAnalysisMetric[] = ['assets', 'operating', 'inventory', 'abnormal', 'pending']; if (!body || !metrics.includes(body.metric) || !validAnalysisFilters(body.filters || {}) || !validAnalysisScope(body.scope || {}) || !validAnalysisFilterValues(filterValues) || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) { return c.json({ error: '下钻条件无效' }, 400); } const now = body.generatedAt === undefined ? Date.now() : typeof body.generatedAt === 'string' ? Date.parse(body.generatedAt) : NaN; if (!Number.isFinite(now) || now > Date.now() + 60000) return c.json({ error: '统计时间无效,请刷新分析' }, 400); let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), body.scope || {}); if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys }); const approvals = await loadMovementApprovalTimes(vehicles); const filtered = vehicles.filter(vehicle => { const cell = analysisCellForVehicle(vehicle, approvals, now); return matchesAnalysisMetric(cell, body.metric) && matchesAnalysisFilters(cell, body.filters || {}) && matchesAnalysisFilterValues(cell, filterValues); }); return c.json(filtered.map(vehicle => { const result = vehicleListItem(vehicle); const approvedAt = approvals.byMoveId.get(vehicle.abnormalMove?.id || ''); return { ...result, abnormalMove: result.abnormalMove ? { ...result.abnormalMove, approvalPassedAt: approvedAt || null, elapsedDays: abnormalElapsedDays(approvedAt, now), } : null, }; })); }); // POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown. app.on(['GET', 'POST'], '/list', async (c) => { let inventoryKeys: Set | undefined; if (c.req.method === 'POST') { const body = await c.req.json().catch(() => null); if (!body || !Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')) { return c.json({ error: '库存明细范围无效' }, 400); } inventoryKeys = new Set(body.inventoryKeys); } const vehicles = await getVehiclesForUser(c); const filtered = await filterVehicleListScope(vehicles, { ...c.req.query(), inventoryKeys: inventoryKeys ? [...inventoryKeys] : undefined }); return c.json(filtered.map(vehicleListItem)); }); async function filterVehicleListScope(vehicles: Vehicle[], scope: AssetAnalysisScope): Promise { const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = scope; let filtered = scope.source === 'region' ? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v)) : vehicles; const subject = (scope.subject || '').trim(); if (subject) filtered = filtered.filter(v => v.subjectOrg === subject); const inventoryKeys = scope.inventoryKeys ? new Set(scope.inventoryKeys) : undefined; if (inventoryKeys) { filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v)))); } // 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) => isOperatingVehicle(v)); 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 (vehicleType === '区域其他' && scope.source === 'region') { filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type)); } else 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 (brand) { filtered = filtered.filter((v) => (v.brandLabel || '未知') === brand); } 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, scope.source); } if (status && status !== 'All') { filtered = filtered.filter((v) => v.status === status); } if (category) { if (category === 'Inventory') { filtered = filtered.filter((v) => isInventoryVehicle(v)); } else if (category === 'Abnormal') { filtered = filtered.filter((v) => isInventoryAbnormal(v)); } else if (category === 'Operating') { filtered = filtered.filter((v) => isOperatingVehicle(v)); } else if (category === 'Pending') { filtered = filtered.filter((v) => isInventoryPending(v)); } } 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 filtered; } function vehicleListItem(v: Vehicle) { return { id: v.id, plateNumber: v.plateNumber, vin: v.vin, type: v.type, model: v.model, location: v.location, color: v.color, operationStatus: v.operationStatus, inventoryKey: inventoryKey(inventoryDimensions(v)), province: v.province, city: v.city, status: v.status, ownership: v.ownership, rentCompany: v.rentCompany, contractNo: v.contractNo, customerName: v.customerName, abnormalMove: v.abnormalMove ?? null, pendingDelivery: v.pendingDelivery ?? null, subjectOrg: v.subjectOrg, departmentName: v.departmentName, customerManager: v.customerManager, opsManagers: v.opsManagers || null, brandLabel: v.brandLabel, orgName: v.orgName, }; } // Inventory keeps its existing aggregate visibility; vehicle details retain user permissions. const INVENTORY_TYPE_NAMES: Record = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' }; function inventoryDimensions(v: Vehicle) { return { region: mapMacroRegion(v.province, v.city), city: resolveCity(v.city, v.province), brand: v.brandLabel || '未知', type: INVENTORY_TYPE_NAMES[classifyVehicleType({ ...v, model: v.model || "" })], model: v.model || "未标注型号" }; } app.get('/inventory-stats', async (c) => { const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles()); const groups = new Map & InventoryCounts & { batch: string; variants: Record }>(); for (const v of vehicles.filter(isInventoryVehicle)) { const dimensions = inventoryDimensions(v); const key = inventoryKey(dimensions); const row = groups.get(key) || { ...dimensions, batch: v.model, ...emptyInventoryCounts(), variants: {} }; const color = inventoryColor(v.color); const variant = row.variants[color] || emptyInventoryCounts(); for (const count of [row, variant]) { count.quantity += 1; count[inventoryOperation(v.operationStatus)] += 1; if (isInventoryPending(v)) count.pending += 1; if (isInventoryAbnormal(v)) { count.abnormal += 1; const type = v.abnormalMove?.type || '未提供'; count.abnormalTypes[type] = (count.abnormalTypes[type] || 0) + 1; } } row.variants[color] = variant; groups.set(key, row); } return c.json([...groups.values()].sort((a, b) => b.quantity - a.quantity)); }); // 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'; { // Public current inventory does not grant access to historical delivery/return events. const vehicles = await getVehiclesForUser(c, type === 'pending'); 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 // 资产流转日报:按运维签章时间统计业务车次;交车包含关联替换单的记录,替换作为其中的子项,同车多笔业务分别累计。 app.get('/flow-stats', async (c) => { const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end')); const allowedVehicles = await getVehiclesForUser(c, false); 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), contractNo: row.contract_no || null, projectName: row.project_name || null, })); 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; if (item.type === 'replaced') stat.delivered += 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 ? filterAssetVehiclesByPermission(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 (isInventoryVehicle(v)) s.inventory += 1; if (isOperatingVehicle(v)) 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) => isOperatingVehicle(v)); 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;