feat: tab导航、recharts图表、库存统计、出勤率里程、区域城市下钻、数据一致性修复
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增tab导航(总览/按部门/按区域/按客户)+ 移动端底部导航 - 新增recharts柱状图(区域分布)和饼图(客户分布) - 新增库存统计模块(按区域/按车型,筛选面板) - 对接ln_vehicle_day_mileage表实现出勤率和日均里程 - 区域运营支持区域→城市→车型三级下钻 - 修复ownership取字段错误(改用truck_rent_status) - 修复部门统计闲置定义(当日无行驶里程) - 修复区域图表"其他"重复问题(后端Top N合并) - 修复城市名空值降级(resolveCity取province) - 修复下钻数据不一致(统一category/vehicleType参数) - 扩展/list端点支持大区过滤和未分配匹配 - 所有筛选改为searchable datalist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,15 +156,23 @@ function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pend
|
||||
return 'Inventory';
|
||||
}
|
||||
|
||||
// Map ownership status
|
||||
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
|
||||
function mapOwnership(ascriptionLabel: string | null): string {
|
||||
if (!ascriptionLabel) return 'Self';
|
||||
const s = ascriptionLabel.trim();
|
||||
if (s === '自有') return 'Self';
|
||||
if (s === '外租') return 'Leased';
|
||||
// Map ownership from truck_rent_status (rentStatusLabel)
|
||||
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
|
||||
function mapOwnership(rentStatusLabel: string | null): string {
|
||||
if (!rentStatusLabel) return 'Unknown';
|
||||
const s = rentStatusLabel.trim();
|
||||
if (s === '自营') return 'Self';
|
||||
if (s === '租赁') return 'Leased';
|
||||
if (s === '挂靠') return 'Hanging';
|
||||
return 'Self';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Resolve city name: clean brackets, fallback to province for municipalities
|
||||
function resolveCity(city: string | null, province: string | null): string {
|
||||
const c = (city || '').replace(/[\[\]]/g, '').trim();
|
||||
if (c) return c;
|
||||
const p = (province || '').replace(/[\[\]]/g, '').trim();
|
||||
return p || '其他';
|
||||
}
|
||||
|
||||
// Derive vehicle type category from model label
|
||||
@@ -268,7 +276,7 @@ function transformRow(row: VehicleRow): Vehicle {
|
||||
province: row.省,
|
||||
city: row.市,
|
||||
status: mapStatus(row.车辆租赁状态Label),
|
||||
ownership: mapOwnership(row.车辆归属状态Label),
|
||||
ownership: mapOwnership(row.车辆租赁状态Label),
|
||||
rentCompany: row.租赁公司 || '',
|
||||
contractNo: row.合同编码,
|
||||
customerName: row.客户名称,
|
||||
@@ -639,68 +647,168 @@ app.get('/inventory-analysis', async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dept-stats — department & manager breakdown
|
||||
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
|
||||
app.get('/dept-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const withManager = vehicles.filter((v) => v.customerManager && v.status === 'Operating');
|
||||
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
||||
|
||||
// Query mileage data: last 30 days attendance & avg mileage per plate
|
||||
// + today's mileage for idle detection
|
||||
const [[mileageRows], [todayRows]] = await Promise.all([
|
||||
pool.query<any[]>(`
|
||||
SELECT plateNumber,
|
||||
COUNT(CASE WHEN dayMileage > 0 THEN 1 END) AS activeDays,
|
||||
COUNT(*) AS totalDays,
|
||||
AVG(dayMileage) AS avgMileage
|
||||
FROM ln_vehicle_day_mileage
|
||||
WHERE dates >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY plateNumber
|
||||
`),
|
||||
pool.query<any[]>(`
|
||||
SELECT plateNumber, dayMileage
|
||||
FROM ln_vehicle_day_mileage
|
||||
WHERE dates = CURDATE()
|
||||
`),
|
||||
]);
|
||||
const mileageMap = new Map<string, { activeDays: number; totalDays: number; avgMileage: number }>();
|
||||
for (const row of mileageRows as any[]) {
|
||||
mileageMap.set(row.plateNumber, {
|
||||
activeDays: Number(row.activeDays),
|
||||
totalDays: Number(row.totalDays),
|
||||
avgMileage: Number(row.avgMileage),
|
||||
});
|
||||
}
|
||||
const todayMileageMap = new Map<string, number>();
|
||||
for (const row of todayRows as any[]) {
|
||||
todayMileageMap.set(row.plateNumber, Number(row.dayMileage));
|
||||
}
|
||||
|
||||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of withManager) {
|
||||
const dept = v.departmentName || '未分配';
|
||||
const mgr = v.customerManager!;
|
||||
const mgr = v.customerManager || '未分配';
|
||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||
const mgrMap = deptMap.get(dept)!;
|
||||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||||
mgrMap.get(mgr)!.push(v);
|
||||
}
|
||||
|
||||
// Compute attendance & mileage for a set of vehicles
|
||||
const getMileageStats = (vList: Vehicle[]) => {
|
||||
let totalActive = 0;
|
||||
let totalDays = 0;
|
||||
let totalMileage = 0;
|
||||
let count = 0;
|
||||
for (const v of vList) {
|
||||
const m = mileageMap.get(v.plateNumber);
|
||||
if (m) {
|
||||
totalActive += m.activeDays;
|
||||
totalDays += m.totalDays;
|
||||
totalMileage += m.avgMileage;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
attendanceRate: totalDays > 0 ? Math.round((totalActive / totalDays) * 1000) / 10 : 0,
|
||||
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
|
||||
const allDeptVehicles = Array.from(mgrMap.values()).flat();
|
||||
const deptMileage = getMileageStats(allDeptVehicles);
|
||||
const managers = Array.from(mgrMap.entries())
|
||||
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
return {
|
||||
department,
|
||||
totalAssets: allDeptVehicles.length,
|
||||
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
|
||||
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
|
||||
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) => b.totalAssets - a.totalAssets);
|
||||
}).sort((a, b) => {
|
||||
// 按部门名中的数字排序(业务一部=1, 业务二部=2, ...)
|
||||
const numMap: Record<string, number> = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
|
||||
const getNum = (name: string) => {
|
||||
const m = name.match(/[一二三四五六七八九十]/);
|
||||
return m ? (numMap[m[0]] || 99) : 99;
|
||||
};
|
||||
return getNum(a.department) - getNum(b.department);
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/region-stats — macro-region breakdown for operating vehicles
|
||||
// GET /api/vehicles/region-stats — macro-region with city drill-down
|
||||
app.get('/region-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
||||
|
||||
const regionMap = new Map<string, Vehicle[]>();
|
||||
const regionCityMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of operating) {
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(v);
|
||||
const 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[]) =>
|
||||
['4.5T', '18T', '49T'].map((type) => {
|
||||
const tv = vList.filter((v) => v.type === type);
|
||||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
||||
}).filter((t) => t.total > 0);
|
||||
|
||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||
const result = regionOrder
|
||||
.filter((r) => regionMap.has(r))
|
||||
.filter((r) => regionCityMap.has(r))
|
||||
.map((region) => {
|
||||
const rv = regionMap.get(region)!;
|
||||
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
|
||||
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
|
||||
const typeVehicles = rv.filter((v) => v.type === type);
|
||||
return {
|
||||
type,
|
||||
total: typeVehicles.length,
|
||||
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
|
||||
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
|
||||
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
};
|
||||
}).filter((t) => t.total > 0);
|
||||
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);
|
||||
|
||||
return { region, totalAssets: rv.length, operatingCount: rv.filter((v) => v.status === 'Operating').length, inventoryCount: rv.filter((v) => v.status === 'Inventory').length, customers, typeBreakdown };
|
||||
// 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);
|
||||
@@ -709,11 +817,11 @@ app.get('/region-stats', async (c) => {
|
||||
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
|
||||
app.get('/customer-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
|
||||
const custMap = new Map<string, Vehicle[]>();
|
||||
for (const v of operating) {
|
||||
const cust = v.customerName!;
|
||||
const cust = v.customerName || '未分配客户';
|
||||
if (!custMap.has(cust)) custMap.set(cust, []);
|
||||
custMap.get(cust)!.push(v);
|
||||
}
|
||||
@@ -742,7 +850,8 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||
'4.5T冷链': (v) => v.type === '4.5T' && v.model.includes('冷链'),
|
||||
'18T': (v) => v.type === '18T',
|
||||
'49T': (v) => v.type === '49T',
|
||||
'其他': (v) => !['4.5T', '18T', '49T'].includes(v.type),
|
||||
'挂车': (v) => v.type === '挂车' || v.model.includes('挂车'),
|
||||
'其他': (v) => classifyVehicleType(v) === 'other',
|
||||
};
|
||||
|
||||
// GET /api/vehicles/list — flat list with optional filters
|
||||
@@ -761,10 +870,15 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.model === model);
|
||||
}
|
||||
if (location && location !== 'All') {
|
||||
// Support both display region names and inventory region names
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation);
|
||||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
if (macroRegions.includes(location) || location === '其他') {
|
||||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
} else {
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
}
|
||||
if (status && status !== 'All') {
|
||||
filtered = filtered.filter((v) => v.status === status);
|
||||
@@ -777,10 +891,10 @@ app.get('/list', async (c) => {
|
||||
}
|
||||
}
|
||||
if (manager) {
|
||||
filtered = filtered.filter((v) => v.customerManager === manager);
|
||||
filtered = filtered.filter((v) => manager === '未分配' ? !v.customerManager : v.customerManager === manager);
|
||||
}
|
||||
if (customer) {
|
||||
filtered = filtered.filter((v) => v.customerName === customer);
|
||||
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
|
||||
}
|
||||
if (isColdChain !== undefined) {
|
||||
const wantCold = isColdChain === 'true';
|
||||
@@ -795,6 +909,7 @@ app.get('/list', async (c) => {
|
||||
filtered.map((v) => ({
|
||||
id: v.id,
|
||||
plateNumber: v.plateNumber,
|
||||
vin: v.vin,
|
||||
type: v.type,
|
||||
model: v.model,
|
||||
location: v.location,
|
||||
@@ -832,7 +947,7 @@ app.get('/inventory-stats', async (c) => {
|
||||
const typeCategory = classifyVehicleType(v);
|
||||
const typeName = TYPE_NAME_MAP[typeCategory];
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
const city = v.city || '其他';
|
||||
const city = resolveCity(v.city, v.province);
|
||||
const brand = v.brandLabel || '未知';
|
||||
const model = v.model;
|
||||
const batch = v.contractNo || 'N/A';
|
||||
@@ -907,4 +1022,31 @@ app.get('/debug', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
|
||||
app.get('/region-chart', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
|
||||
const top = Number(c.req.query('top')) || 8;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of operating) {
|
||||
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// 分离"其他",对非"其他"排序取 Top N,剩余全部合入"其他"
|
||||
const otherCount = counts.get('其他') || 0;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user