Files
ln-bi/src/server/routes/vehicles.ts
T
2026-10-10 21:43:03 +08:00

763 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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<Vehicle[]> {
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<string, number>,
),
};
});
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<string, number>,
),
};
});
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<string, number>();
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<string, Map<string, Vehicle[]>>();
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<string, Map<string, Vehicle[]>>();
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<string, Vehicle[]>();
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<string> | 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<Vehicle[]> {
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<string, number>();
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<string, string> = { 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<string, ReturnType<typeof inventoryDimensions> & InventoryCounts & { batch: string; variants: Record<string, InventoryCounts> }>();
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<string, { date: string; delivered: number; returned: number; replaced: number; total: number }>();
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<string, { total: number; inventory: number; operating: number }>();
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<string, number>;
if (groupBy === 'province') {
counts = new Map<string, number>();
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<string, string>();
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<string, number>();
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;