feat: tab导航、recharts图表、库存统计、出勤率里程、区域城市下钻、数据一致性修复
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:
kkfluous
2026-03-28 18:09:18 +08:00
parent 709e6d4238
commit 2ba25427de
6 changed files with 736 additions and 236 deletions

View File

@@ -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;