Files
ln-bi/src/server/routes/vehicles.ts
T
lingniu b0caa5afcb
ci/woodpecker/push/woodpecker Pipeline was successful
feat: polish BI dashboards and bump version
2026-06-27 21:59:33 +08:00

1427 lines
60 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 { Hono } from 'hono';
import pool from '../db.js';
import type {
VehicleRow,
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';
const app = new Hono();
const MAIN_SQL = `SELECT
CAST(vi.id AS CHAR) AS id,
vi.plate_number AS 车牌号,
vi.vin AS vin,
vm.brand AS 车辆品牌,
vm.model AS 车辆型号,
vi.body_color AS 车辆颜色,
vi.rental_company AS 租赁公司,
CASE vi.vehicle_source
WHEN '0' THEN '自有'
WHEN '1' THEN '挂靠'
WHEN '2' THEN '外租'
WHEN '3' THEN '自有'
ELSE vi.actual_ownership
END AS 车辆归属状态Label,
vm.model AS 车辆型号Label,
vm.vehicle_type AS 车辆类型参数,
vi.operation_city AS 库存区域,
vs.vehicle_status AS 车辆租赁状态,
vs.operation_status AS 车辆租赁状态Label,
CASE WHEN COALESCE(vs.operation_status, '') = '5' THEN 0 ELSE 1 END AS 是否营运,
COALESCE(info_province.NAME, NULLIF(info.province, ''), vi.province_name, vi_province.NAME, NULLIF(vi.province, '')) AS 省,
COALESCE(info_city.NAME, NULLIF(info.city, ''), vi.city_name, vi_city.NAME, vi_operation_city.NAME, NULLIF(vi.city, ''), NULLIF(vi.operation_city, '')) AS 市,
info.lat AS 纬度,
info.lng AS 经度,
CASE vm.brand
WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END
WHEN 'yuejin' THEN '跃进'
WHEN 'feichi' THEN '飞驰'
WHEN 'sulong' THEN '苏龙'
WHEN 'higer' THEN '海格'
WHEN 'dongfeng' THEN '东风'
WHEN 'yutong' THEN '宇通'
WHEN 'chufeng' THEN '楚风'
WHEN 'tonghua' THEN '通华'
WHEN 'maxus' THEN '大通'
WHEN 'mingwei' THEN '明威'
WHEN 'wanfeng' THEN '万风'
WHEN 'shujie' THEN '舒捷'
WHEN 'denza' THEN '腾势'
WHEN 'hongyan' THEN '红岩'
WHEN 'yuanchang brand' THEN '远程牌'
WHEN 'others' THEN '其他'
ELSE vm.brand
END AS 车辆品牌Label,
c.id AS 合同ID,
COALESCE(c.contract_code, vor.contract_code, vi.contract_code) AS 合同编码,
COALESCE(c.customer_name, vor.customer_name, ci.customer_name) AS 客户名称,
c.signing_company AS 合同归属公司,
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
NULLIF(vi.registered_ownership, '') AS 主体,
COALESCE(c.project_name, vor.project_name) AS 项目名称,
COALESCE(c.business_manager_name, vor.business_manager) AS 客户经理,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
ON vs.vehicle_id = vi.id
AND vs.del_flag = 0
LEFT JOIN vehicle_model vm
ON vm.id = vi.vehicle_model_id
AND vm.del_flag = '0'
LEFT JOIN tab_truck_remote_sync_realtime_info info
ON info.plate_number = vi.plate_number
AND info.is_deleted = 0
LEFT JOIN common_district info_province
ON info_province.CODE = info.province COLLATE utf8mb4_unicode_ci
AND info_province.STATUS = 'VALID'
LEFT JOIN common_district info_city
ON info_city.CODE = info.city COLLATE utf8mb4_unicode_ci
AND info_city.STATUS = 'VALID'
LEFT JOIN common_district vi_province
ON vi_province.CODE = vi.province COLLATE utf8mb4_unicode_ci
AND vi_province.STATUS = 'VALID'
LEFT JOIN common_district vi_city
ON vi_city.CODE = vi.city COLLATE utf8mb4_unicode_ci
AND vi_city.STATUS = 'VALID'
LEFT JOIN common_district vi_operation_city
ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci
AND vi_operation_city.STATUS = 'VALID'
LEFT JOIN vehicle_lease_order_record vor
ON vor.vehicle_id = vi.id
AND vor.del_flag = '0'
AND vor.id = (
SELECT MAX(vor2.id)
FROM vehicle_lease_order_record vor2
WHERE vor2.vehicle_id = vi.id
AND vor2.del_flag = '0'
)
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = vor.contract_id
AND c.del_flag = '0'
LEFT JOIN customer_info ci
ON ci.id = vi.customer_id
AND ci.del_flag = '0'
WHERE vi.del_flag = '0'
AND COALESCE(vs.operation_status, '') <> '5'`;
// Region mapping: province/city -> display region
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
export function mapRegion(province: string | null, city: string | null): string {
if (!province && !city) return '其他';
const loc = (city || province || '').trim();
if (loc.includes('嘉兴') || loc.includes('浙江') || loc.includes('上海') || loc.includes('江苏')) return '嘉兴';
if (loc.includes('广东') || loc.includes('广州') || loc.includes('深圳') || loc.includes('佛山') || loc.includes('东莞')) return '广东';
if (loc.includes('北京')) return '北京';
if (loc.includes('新疆') || loc.includes('乌鲁木齐')) return '新疆';
// Also check province
const prov = (province || '').trim();
if (prov.includes('浙江') || prov.includes('上海') || prov.includes('江苏')) return '嘉兴';
if (prov.includes('广东')) return '广东';
if (prov.includes('北京')) return '北京';
if (prov.includes('新疆')) return '新疆';
return '其他';
}
function mapInventoryRegion(region: string): string {
if (region === '嘉兴') return '江浙沪';
if (region === '广东') return '广东';
if (region === '新疆') return '新疆';
return '其它';
}
// Macro-region mapping: province/city -> 华东/华南/华北/华中/西南/西北/其他
function mapMacroRegion(province: string | null, city: string | null): string {
const prov = (province || '').trim();
const c = (city || '').trim();
const loc = prov + c;
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(loc)) return '华东';
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(loc)) return '华南';
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(loc)) return '华北';
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(loc)) return '华中';
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(loc)) return '西南';
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(loc)) return '西北';
return '其他';
}
type VehicleTypeCounts = { t4_5: number; t4_5c: number; t18: number; t49: number; trailer: number; other: number; total: number };
function classifyVehicleType(v: Vehicle): keyof Omit<VehicleTypeCounts, 'total'> {
if (v.type === '4.5T' && !v.model.includes('冷链')) return 't4_5';
if (v.type === '4.5T' && v.model.includes('冷链')) return 't4_5c';
if (v.type === '18T') return 't18';
if (v.type === '49T') return 't49';
if (v.type === '挂车' || v.model.includes('挂车')) return 'trailer';
return 'other';
}
function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
const counts: VehicleTypeCounts = { t4_5: 0, t4_5c: 0, t18: 0, t49: 0, trailer: 0, other: 0, total: 0 };
for (const v of vehicles) {
counts[classifyVehicleType(v)]++;
counts.total++;
}
return counts;
}
// Map operation status to frontend status.
// ln_asset_management.vehicle_status.operation_status:
// 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
function mapStatus(operationStatus: string | null, vehicleStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
const op = (operationStatus || '').trim();
const vehicle = (vehicleStatus || '').trim();
if (vehicle === '4') return 'Pending';
if (vehicle === '14') return 'Abnormal';
if (op === '1' || op === '2') return 'Operating';
if (op === '3' || op === '4') return 'Inventory';
if (op === '租赁' || op === '自营') return 'Operating';
if (op === '可运营' || op === '待运营' || op === '在库') return 'Inventory';
if (op === '异动') return 'Abnormal';
return 'Inventory';
}
// Map ownership from vehicle_info.vehicle_source.
// ln_asset_management vehicle_source values: 1=挂靠, 2=外租, 3=自有. Keep 0=自有 for legacy rows.
function mapOwnership(rentStatusLabel: string | null): string {
if (!rentStatusLabel) return 'Unknown';
const s = rentStatusLabel.trim();
if (s === '0') return 'Self';
if (s === '1') return 'Hanging';
if (s === '2') return 'Leased';
if (s === '3') return 'Self';
if (s === '自有') return 'Self';
if (s === '外租') return 'Leased';
if (s === '自营') return 'Self';
if (s === '租赁') return 'Leased';
if (s === '挂靠') return 'Hanging';
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 page category from ln_asset_management.vehicle_model.
// vehicle_type is a new-system category code, not the old lingniu_prod.dic_truck_type code:
// 1 = 4.5T, 2 = 18T / other truck-like models, 3 = tractor head, 5/6 = trailers.
function deriveType(modelLabel: string | null, vehicleTypeCode: string | null): string {
const label = (modelLabel || '').trim();
const code = (vehicleTypeCode || '').trim();
if (label.includes('半挂车') || code === '5' || code === '6') return '挂车';
if (label.includes('4.5吨')) return '4.5T';
if (label.includes('18吨')) return '18T';
if (label.includes('49吨')) return '49T';
if (label.includes('35吨')) return '35T';
if (code === '1') return '4.5T';
if (code === '3') return '49T';
if (label.includes('叉车')) return '叉车';
return '其他车型';
}
function normalizeModelLabel(modelLabel: string | null): string | null {
const label = (modelLabel || '').trim();
if (label === '帕力安牌4.5吨冷链车') return '4.5吨冷链车';
if (label === '帕力安牌18吨双飞翼货车') return '18吨双飞翼货车';
if (label === '海格牌18吨双飞翼货车') return '18吨双飞翼货车';
return label || null;
}
// Tag → alias mapping with sort order
// tag is generated as: brand-modelLabel-color[+rentCompany if 外租]
// Some tags are merged (e.g. 嘉氢 red + 嘉氢 blue/green → one alias)
const MODEL_ALIAS_MAP: Record<string, { alias: string; order: number }> = {
// 4.5T 普货
'现代-4.5吨货车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T普货(交投)', order: 101 },
'现代-4.5吨货车-白恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色恒运': { alias: '现代4.5T普货(恒运)', order: 102 },
'现代-4.5吨货车-白色': { alias: '现代4.5T普货', order: 101 },
'现代-4.5吨货车-白': { alias: '现代4.5T普货', order: 101 },
// 4.5T 冷链
'帕力安牌-4.5吨冷链车-白色广州开发区交投氢能运营管理有限公司': { alias: '现代4.5T冷链(交投)', order: 201 },
'帕力安牌-4.5吨冷链车-白色': { alias: '现代4.5T冷链(羚牛)', order: 202 },
'跃进-4.5吨冷链车-白/绿/灰': { alias: '跃进4.5T冷链', order: 203 },
// 18T
'飞驰-18吨厢式货车-红': { alias: '飞驰18T(红车)', order: 301 },
'飞驰-18吨厢式货车-白/绿': { alias: '飞驰18T(白车)', order: 302 },
'楚风-18吨厢式货车-白': { alias: '楚风18T厢货', order: 303 },
'苏龙-18吨双飞翼货车-白': { alias: '苏龙18T飞翼', order: 304 },
'苏龙-18吨双飞翼货车-白色': { alias: '苏龙18T飞翼', order: 304 }, // dirty data, merge
'苏龙-18吨双飞翼货车-白安吉天地物流科技有限公司': { alias: '苏龙18T飞翼(安吉)', order: 305 },
'帕力安牌-18吨双飞翼货车-白': { alias: '现代18T双飞翼(羚牛)', order: 306 },
'帕力安牌-18吨双飞翼货车-白/绿': { alias: '现代18T双飞翼(羚牛)', order: 306 },
// 49T
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
'飞驰-49吨牵引车头-红': { alias: '49T飞驰(红)', order: 402 },
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
'楚风-49吨牵引车头-蓝/黑': { alias: '49T楚风', order: 406 },
'楚风-49吨牵引车头-蓝/黑海珀特科技(北京)有限公司': { alias: '49T楚风(海珀特)', order: 406 },
// 其他
'红岩-35吨牵引车头-红色': { alias: '35T油车', order: 501 },
'其他-氢能叉车-蓝白绿': { alias: '氢能叉车', order: 502 },
'通华-重型集装箱半挂车-红色浙江锦昌仓储有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色嘉兴市鼎义物流有限公司': { alias: '挂车', order: 503 },
'通华-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'大通-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红色': { alias: '挂车', order: 503 },
'明威-重型集装箱半挂车-红': { alias: '挂车', order: 503 },
'万风-重型平板半挂车-红': { alias: '挂车', order: 503 },
'舒捷-SJ型蓄电池观光车-蓝白': { alias: '观光车', order: 504 },
'东风-挂靠油车-白色': { alias: '公务车/挂靠车', order: 505 },
'东风-挂靠油车-白': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-黑': { alias: '公务车/挂靠车', order: 505 },
'腾势-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-蓝色': { alias: '公务车/挂靠车', order: 505 },
'其他-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
'远程牌-公务用车/小客车-白': { alias: '公务车/挂靠车', order: 505 },
'大通-公务用车/小客车-灰': { alias: '公务车/挂靠车', order: 505 },
};
function deriveModelTag(
brandLabel: string | null,
modelLabel: string | null,
color: string | null,
ownershipLabel: string | null,
rentCompany: string | null,
): string {
const brand = (brandLabel || '').trim();
const model = (normalizeModelLabel(modelLabel) || '').trim();
const c = (color || '').trim();
if (model === '公务用车/小客车') return '公务车/挂靠车';
const isRented = ownershipLabel?.trim() === '外租';
const company = isRented ? (rentCompany || '').trim() : '';
if (!brand && !model) return '未知车型';
const tag = `${brand}-${model}-${c}${company}`;
const mapped = MODEL_ALIAS_MAP[tag];
return mapped ? mapped.alias : tag;
}
function getModelOrder(model: string): number {
// Find the order from alias mapping
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
if (entry.alias === model) return entry.order;
}
return 999;
}
function transformRow(row: VehicleRow): Vehicle {
const region = mapRegion(row., row.);
return {
id: row.id,
plateNumber: row.车牌号 || '',
vin: row.vin || '',
type: deriveType(row.车辆型号Label, row.车辆类型参数),
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
color: row.车辆颜色 || '',
location: region,
region,
province: row.省,
city: row.市,
status: mapStatus(row.车辆租赁状态Label, row.车辆租赁状态),
operationStatus: row.车辆租赁状态Label,
ownership: mapOwnership(row.车辆归属状态Label),
rentCompany: row.租赁公司 || '',
contractNo: row.合同编码,
customerName: row.客户名称,
orgName: row.合同归属公司,
departmentName: row.合同归属部门,
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
managerId: row.经理ID || null,
brandLabel: row.车辆品牌Label,
};
}
// Cache for vehicles data (refresh every 5 minutes)
let cachedVehicles: Vehicle[] = [];
let lastFetchTime = 0;
const CACHE_TTL = 60 * 1000;
async function getVehicles(): Promise<Vehicle[]> {
const now = Date.now();
if (cachedVehicles.length > 0 && now - lastFetchTime < CACHE_TTL) {
return cachedVehicles;
}
const [rows] = await pool.query<any[]>(MAIN_SQL);
cachedVehicles = (rows as VehicleRow[]).map(transformRow);
lastFetchTime = now;
return cachedVehicles;
}
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
const all = await 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);
}
function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
return regions.reduce((acc, reg) => {
acc[reg] = vehicles.filter((v) => v.location === reg).length;
return acc;
}, {} as Record<string, number>);
}
// Weekly truck ID sets, cached
interface WeeklyTruckIds {
pending: Set<string>;
delivered: Set<string>;
returned: Set<string>;
replaced: Set<string>;
}
let cachedWeeklyTruckIds: WeeklyTruckIds | null = null;
let weeklyTruckIdsLastFetch = 0;
async function getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
const now = Date.now();
if (cachedWeeklyTruckIds && now - weeklyTruckIdsLastFetch < CACHE_TTL) {
return cachedWeeklyTruckIds;
}
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM vehicle_status
WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM delivery_vehicle
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}
AND delivery_status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(vehicle_id AS CHAR) AS truck_id
FROM return_vehicle_task
WHERE del_flag='0'
AND vehicle_id IS NOT NULL
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}
AND status IN (2,3,5)`),
pool.query<any[]>(`SELECT CAST(new_vehicle_id AS CHAR) AS truck_id
FROM vehicle_replacement
WHERE del_flag='0'
AND new_vehicle_id IS NOT NULL
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}
AND status=20`),
]);
const toSet = (rows: any[]) => new Set((rows as any[]).map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
cachedWeeklyTruckIds = {
pending: toSet(pendingRows as any[]),
delivered: toSet(deliveredRows as any[]),
returned: toSet(returnedRows as any[]),
replaced: toSet(replacedRows as any[]),
};
weeklyTruckIdsLastFetch = now;
return cachedWeeklyTruckIds;
}
function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds) {
const strIds = list.map((v) => String(v.id));
return {
total: list.length,
inventory: list.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
inventoryRegions: getRegionCounts(
list.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'),
REGIONS,
),
pending: list.filter((v) => v.status === 'Pending').length,
operating: list.filter((v) => v.status === 'Operating').length,
weeklyDelivered: weeklyIds ? strIds.filter((id) => weeklyIds.delivered.has(id)).length : 0,
weeklyReturned: weeklyIds ? strIds.filter((id) => weeklyIds.returned.has(id)).length : 0,
weeklyReplaced: weeklyIds ? strIds.filter((id) => weeklyIds.replaced.has(id)).length : 0,
};
}
// Week range: last Saturday 00:00 to this Friday 23:59
// MySQL: WEEKDAY() returns 0=Monday..6=Sunday, Saturday=5
// "上周六-本周五": offset from today to last Saturday
const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
type FlowType = 'delivered' | 'returned' | 'replaced';
interface FlowDetailRow {
id: string;
type: FlowType;
type_label: string;
stat_date: string;
truck_id: string;
plate_number: string;
event_time: string | null;
submit_time: string | null;
department: string | null;
manager: string | null;
customer_name: string | null;
}
function formatDateOnly(value: Date): string {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isDateParam(value: string | undefined | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function addDateDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + days);
return formatDateOnly(d);
}
function listDateRange(start: string, end: string): string[] {
const dates: string[] = [];
let cursor = start;
while (cursor <= end && dates.length <= 370) {
dates.push(cursor);
cursor = addDateDays(cursor, 1);
}
return dates;
}
function normalizeDateRange(startRaw: string | undefined, endRaw: string | undefined): { start: string; end: string } {
const today = formatDateOnly(new Date());
const defaultStart = addDateDays(today, -29);
let start = isDateParam(startRaw) ? startRaw : defaultStart;
let end = isDateParam(endRaw) ? endRaw : today;
if (start > end) [start, end] = [end, start];
if (listDateRange(start, end).length > 370) start = addDateDays(end, -369);
return { start, end };
}
interface WeeklyStats {
pendingDelivery: number;
weeklyNew: number;
weeklyRemoved: number;
weeklyDelivered: number;
weeklyReturned: number;
weeklyReplaced: number;
}
// 交车单 SQL
const DELIVERED_SQL = `SELECT
dv.id, DATE(dv.delivery_time) AS handover_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id, dv.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.delivery_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)`;
// 还车单 SQL
const RETURNED_SQL = `SELECT
r.id, DATE(r.arrival_time) AS handover_date,
CAST(r.vehicle_id AS CHAR) AS truck_id, r.plate_number,
c.contract_type,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.arrival_time IS NOT NULL
AND r.status IN (2,3,5)`;
// 替换车单 SQL
const REPLACED_SQL = `SELECT
vr.id, DATE(vr.replace_time) AS handover_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id, vr.new_vehicle_plate AS plate_number,
c.contract_type,
c.customer_name
FROM vehicle_replacement vr
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.replace_time IS NOT NULL
AND vr.status = 20`;
let cachedWeeklyStats: WeeklyStats | null = null;
let weeklyStatsLastFetch = 0;
async function getWeeklyStats(): Promise<WeeklyStats> {
const now = Date.now();
if (cachedWeeklyStats && now - weeklyStatsLastFetch < CACHE_TTL) {
return cachedWeeklyStats;
}
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info WHERE del_flag='0' AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_info vi LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0 WHERE (vi.del_flag='1' OR vs.operation_status='5') AND vi.update_time >= ${WEEK_START_SQL} AND vi.update_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`),
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_replacement
WHERE del_flag='0' AND replace_time IS NOT NULL AND status=20
AND replace_time >= ${WEEK_START_SQL} AND replace_time < ${WEEK_END_SQL}`),
]);
cachedWeeklyStats = {
pendingDelivery: (pendingRows as any[])[0]?.cnt || 0,
weeklyNew: (newRows as any[])[0]?.cnt || 0,
weeklyRemoved: (removedRows as any[])[0]?.cnt || 0,
weeklyDelivered: (deliveredRows as any[])[0]?.cnt || 0,
weeklyReturned: (returnedRows as any[])[0]?.cnt || 0,
weeklyReplaced: (replacedRows as any[])[0]?.cnt || 0,
};
weeklyStatsLastFetch = now;
return cachedWeeklyStats;
}
// GET /api/vehicles/summary
app.get('/summary', async (c) => {
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), 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), getWeeklyTruckIds()]);
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
];
const result: TypeSummary[] = typeFilters.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) => getModelOrder(a.model) - getModelOrder(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 getVehicles());
const typeFilters = [
{ name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') },
{ name: '4.5T冷链', filter: (v: Vehicle) => v.type === '4.5T' && v.model.includes('冷链') },
{ name: '18T', filter: (v: Vehicle) => v.type === '18T' },
{ name: '49T', filter: (v: Vehicle) => v.type === '49T' },
{ name: '其他', filter: (v: Vehicle) => !['4.5T', '18T', '49T'].includes(v.type) },
];
const result: InventoryTypeSummary[] = typeFilters.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 pool.query<any[]>(`
SELECT plate_number, day_mileage
FROM tab_truck_remote_sync_realtime_info
WHERE is_deleted = 0 AND plate_number IS NOT NULL
`);
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);
}
// Compute attendance & avg mileage from realtime data
const getMileageStats = (vList: Vehicle[]) => {
const todayActive = vList.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length;
let totalMileage = 0;
let count = 0;
for (const v of vList) {
const m = todayMileageMap.get(v.plateNumber);
if (m !== undefined && m > 0) {
totalMileage += m;
count++;
}
}
return {
attendanceRate: vList.length > 0 ? Math.round((todayActive / vList.length) * 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) => (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) => {
// 按部门名中的数字排序(业务一部=1, 业务二部=2, ...)
const numMap: Record<string, number> = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
const getNum = (name: string) => {
if (name === '公务车') return 100;
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 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);
});
// Location 过滤器:支持展示区域(嘉兴/广东/北京/新疆/其他)、库存区域(江浙沪/其它)、
// 城市(嘉兴市)、宏观区域(华东/华南/...)。
// '其他' 在两个体系里都存在(资产表的"库存-其他" vs 区域表的"其他"宏观区域),
// 用 source 区分:source==='asset' 时按 v.location 匹配,其它情况按宏观区域匹配。
function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
if (isMacro) {
return vehicles.filter((v) => mapMacroRegion(v.province, v.city) === location);
}
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
const mappedLocation = inventoryRegionMap[location] || location;
return vehicles.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
}
// Vehicle type filter map (same logic as /by-type)
const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
'4.5T普货': (v) => v.type === '4.5T' && !v.model.includes('冷链'),
'4.5T冷链': (v) => v.type === '4.5T' && v.model.includes('冷链'),
'18T': (v) => v.type === '18T',
'49T': (v) => v.type === '49T',
'挂车': (v) => v.type === '挂车' || v.model.includes('挂车'),
'其他': (v) => classifyVehicleType(v) === 'other',
};
// 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 pool.query<any[]>(`SELECT plate_number, day_mileage FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
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 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');
let sql: string;
if (type === 'delivered') {
sql = `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`;
} else if (type === 'returned') {
sql = `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`;
} else if (type === 'replaced') {
sql = `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`;
} else if (type === 'pending') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND vs.vehicle_status='4' AND COALESCE(vs.operation_status, '') <> '5'`;
} else if (type === 'new') {
sql = `SELECT CAST(vi.id AS CHAR) AS truck_id, vi.plate_number, vi.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
FROM vehicle_info vi
LEFT JOIN vehicle_status vs ON vs.vehicle_id=vi.id AND vs.del_flag=0
WHERE vi.del_flag='0' AND COALESCE(vs.operation_status, '') <> '5'
AND vi.create_time >= ${WEEK_START_SQL} AND vi.create_time < ${WEEK_END_SQL} ORDER BY vi.create_time DESC`;
} else {
return c.json([]);
}
const [rows] = await pool.query<any[]>(sql);
let result = rows as any[];
// 按型号/批次/区域过滤:借助缓存车辆集,取 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 sql = `
SELECT *
FROM (
SELECT
CONCAT('delivered-', dv.id) AS id,
'delivered' AS type,
'交车' AS type_label,
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
CAST(dv.vehicle_id AS CHAR) AS truck_id,
dv.plate_number,
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM delivery_vehicle dv
LEFT JOIN delivery_task_subject dts
ON dts.id = dv.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = dv.contract_id
AND c.del_flag = '0'
WHERE dv.del_flag = '0'
AND dv.vehicle_id IS NOT NULL
AND dv.create_time IS NOT NULL
AND dv.delivery_status IN (2,3,5)
AND dv.create_time >= ?
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
CAST(r.vehicle_id AS CHAR) AS truck_id,
r.plate_number,
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM return_vehicle_task r
LEFT JOIN delivery_task_subject dts
ON dts.id = r.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.order_id = r.contract_id
AND c.del_flag = '0'
WHERE r.del_flag = '0'
AND r.vehicle_id IS NOT NULL
AND r.create_time IS NOT NULL
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
UNION ALL
SELECT
CONCAT('replaced-', vr.id) AS id,
'replaced' AS type,
'替换' AS type_label,
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
vr.new_vehicle_plate AS plate_number,
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
c.business_department_name AS department,
c.business_manager_name AS manager,
COALESCE(dts.customer_name, c.customer_name) AS customer_name
FROM vehicle_replacement vr
LEFT JOIN delivery_task_subject dts
ON dts.id = vr.delivery_task_subject_id
AND dts.del_flag = '0'
LEFT JOIN vehicle_lease_contract_info c
ON c.id = vr.contract_id
AND c.del_flag = '0'
WHERE vr.del_flag = '0'
AND vr.new_vehicle_id IS NOT NULL
AND vr.create_time IS NOT NULL
AND vr.status = 20
AND vr.create_time >= ?
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
) flow
ORDER BY flow.submit_time DESC
`;
const [rows] = await pool.query<any[]>(sql, [start, end, start, end, 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 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) => {
lastFetchTime = 0;
weeklyStatsLastFetch = 0;
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]] = await pool.query<any[]>(`SELECT
${WEEK_START_SQL} AS week_start,
${WEEK_END_SQL} AS week_end,
CURDATE() AS today,
WEEKDAY(CURDATE()) AS weekday`);
const [[deliveredAll]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)
AND delivery_time >= ${WEEK_START_SQL} AND delivery_time < ${WEEK_END_SQL}`);
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(delivery_time) AS latest FROM delivery_vehicle
WHERE del_flag='0' AND delivery_time IS NOT NULL AND delivery_status IN (2,3,5)`);
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)
AND arrival_time >= ${WEEK_START_SQL} AND arrival_time < ${WEEK_END_SQL}`);
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
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 pool.query<any[]>(`SELECT plate_number, province FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
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;