1427 lines
60 KiB
TypeScript
1427 lines
60 KiB
TypeScript
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;
|