672 lines
26 KiB
TypeScript
672 lines
26 KiB
TypeScript
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<Vehicle[]> {
|
||
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<string, number>,
|
||
),
|
||
};
|
||
});
|
||
|
||
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<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) => v.status === 'Operating');
|
||
|
||
// 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) => 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<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) => 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<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);
|
||
});
|
||
|
||
// 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<string, number>();
|
||
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<string, string> = {
|
||
t4_5: '4.5T普货',
|
||
t4_5c: '4.5T冷链',
|
||
t18: '18T',
|
||
t49: '49T',
|
||
trailer: '挂车',
|
||
other: '其他',
|
||
};
|
||
|
||
const groups = new Map<string, number>();
|
||
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<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;
|
||
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<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 (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<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;
|