All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 部门Tab:移除多余section header,标签改为总运营车辆/出勤/闲置, 表格移除日均里程列,按业务负责人改为按业务员,所有行加点击下钻 - 部门下钻增加department维度,后端/list接口新增department过滤参数 - 区域Tab:总资产下钻移除错误的category:Operating,库存改为Inventory, 补全source:region和title,图表高度h-72改h-64 - 客户Tab:4.5T/冷链点击增加isColdChain区分,移动端合计badge加下钻, 所有点击补全title - 筛选面板:移除区域和客户的"完成筛选"按钮 - 所有manager下钻补全title字段 - 弹窗统一使用14列完整详情表格(月/部门/负责人/品牌/车型/归属/客户/ 车牌/状态/提车时间/到期时间/区域/离到期/签约公司),移除source条件 - 表格加whitespace-nowrap和w-max,移动端水平滚动不换行 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1066 lines
46 KiB
TypeScript
1066 lines
46 KiB
TypeScript
import { Hono } from 'hono';
|
||
import pool from '../db.js';
|
||
import type {
|
||
VehicleRow,
|
||
Vehicle,
|
||
SummaryData,
|
||
TypeSummary,
|
||
ModelSummary,
|
||
BatchSummary,
|
||
BatchGroup,
|
||
InventoryTypeSummary,
|
||
} from '../types.js';
|
||
|
||
const app = new Hono();
|
||
|
||
const MAIN_SQL = `SELECT
|
||
CAST(truck.id AS CHAR) AS id,
|
||
truck.plate_number AS 车牌号,
|
||
truck.vin AS vin,
|
||
truck.brand AS 车辆品牌,
|
||
truck.model AS 车辆型号,
|
||
truck.color AS 车辆颜色,
|
||
truck.rent_from_company AS 租赁公司,
|
||
dic_ascription_status.dic_name AS 车辆归属状态Label,
|
||
dic_type.dic_name AS 车辆型号Label,
|
||
truck.stock_area AS 库存区域,
|
||
truck.truck_rent_status AS 车辆租赁状态,
|
||
dic_status.dic_name AS 车辆租赁状态Label,
|
||
truck.is_operation AS 是否营运,
|
||
info.province AS 省,
|
||
info.city AS 市,
|
||
info.lat AS 纬度,
|
||
info.lng AS 经度,
|
||
dic_brand.dic_name AS 车辆品牌Label,
|
||
si.contract_id AS 合同ID,
|
||
COALESCE(c.contract_no, si.contract_no) AS 合同编码,
|
||
cus.customer_name AS 客户名称,
|
||
org.org_name AS 合同归属公司,
|
||
dep.dep_name AS 合同归属部门,
|
||
org_truck.org_name AS 主体,
|
||
c.project_name AS 项目名称,
|
||
u.user_name AS 客户经理
|
||
FROM tab_truck truck
|
||
LEFT JOIN tab_truck_remote_sync_realtime_info info
|
||
ON info.id = truck.id
|
||
LEFT JOIN tab_dic dic_type
|
||
ON dic_type.parent_code = 'dic_truck_type'
|
||
AND dic_type.dic_code = truck.model
|
||
AND dic_type.is_deleted = 0
|
||
LEFT JOIN tab_dic dic_status
|
||
ON dic_status.parent_code = 'dic_truck_rent_status'
|
||
AND dic_status.dic_code = truck.truck_rent_status
|
||
AND dic_status.is_deleted = 0
|
||
LEFT JOIN tab_dic dic_brand
|
||
ON dic_brand.parent_code = 'dic_vehicle_brand'
|
||
AND dic_brand.dic_code = truck.brand
|
||
AND dic_brand.is_deleted = 0
|
||
LEFT JOIN tab_truck_status_info si
|
||
ON si.truck_id = truck.id
|
||
AND si.is_deleted = 0
|
||
LEFT JOIN tab_contract c
|
||
ON c.id = si.contract_id
|
||
AND c.is_deleted = 0
|
||
LEFT JOIN tab_customer cus
|
||
ON cus.id = c.customer_id
|
||
AND cus.is_deleted = 0
|
||
LEFT JOIN tab_org org
|
||
ON org.id = c.org_id
|
||
AND org.is_deleted = 0
|
||
LEFT JOIN tab_org org_truck
|
||
ON org_truck.id = truck.org_id
|
||
AND org_truck.is_deleted = 0
|
||
LEFT JOIN tab_dic dic_ascription_status
|
||
ON dic_ascription_status.parent_code = 'dic_truck_ascription_status'
|
||
AND dic_ascription_status.dic_code = truck.ascription_status
|
||
AND dic_ascription_status.is_deleted = 0
|
||
LEFT JOIN tab_user u
|
||
ON u.id = c.bd
|
||
AND u.is_deleted = 0
|
||
LEFT JOIN tab_department dep
|
||
ON dep.id = u.dep_id
|
||
AND dep.is_deleted = 0
|
||
WHERE truck.is_deleted = 0
|
||
AND truck.is_operation = 1`;
|
||
|
||
// Region mapping: province/city -> display region
|
||
const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||
const INVENTORY_REGIONS = ['江浙沪', '广东', '新疆', '其它'] as const;
|
||
|
||
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 rental status to frontend status
|
||
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
||
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pending' | 'Abnormal' {
|
||
if (!rentStatus) return 'Inventory';
|
||
const s = rentStatus.trim();
|
||
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
||
if (s === '在库') return 'Inventory';
|
||
if (s === '待交车') return 'Pending';
|
||
if (s === '异动') return 'Abnormal';
|
||
return 'Inventory';
|
||
}
|
||
|
||
// Map ownership from truck_rent_status (rentStatusLabel)
|
||
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
|
||
function mapOwnership(rentStatusLabel: string | null): string {
|
||
if (!rentStatusLabel) return 'Unknown';
|
||
const s = rentStatusLabel.trim();
|
||
if (s === '自营') return 'Self';
|
||
if (s === '租赁') return 'Leased';
|
||
if (s === '挂靠') return 'Hanging';
|
||
return 'Unknown';
|
||
}
|
||
|
||
// Resolve city name: clean brackets, fallback to province for municipalities
|
||
function resolveCity(city: string | null, province: string | null): string {
|
||
const c = (city || '').replace(/[\[\]]/g, '').trim();
|
||
if (c) return c;
|
||
const p = (province || '').replace(/[\[\]]/g, '').trim();
|
||
return p || '其他';
|
||
}
|
||
|
||
// Derive vehicle type category from model label
|
||
// Actual DB values: 4.5吨冷链车, 4.5吨货车, 18吨双飞翼货车, 18吨厢式货车, 49吨牵引车头, 35吨牵引车头,
|
||
// 重型集装箱半挂车, 重型平板半挂车, 氢能叉车, SJ型蓄电池观光车, 公务用车/小客车, 挂靠油车
|
||
function deriveType(modelLabel: string | null, brandLabel: string | null): string {
|
||
const label = (modelLabel || '').trim();
|
||
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 (label.includes('叉车')) return '叉车';
|
||
if (label.includes('半挂车')) return '挂车';
|
||
return '其他车型';
|
||
}
|
||
|
||
// 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.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 },
|
||
// 49T
|
||
'宇通-49吨牵引车头-白': { alias: '49T宇通', order: 401 },
|
||
'飞驰-49吨牵引车头-白/蓝/绿': { alias: '49T飞驰', order: 402 },
|
||
'飞驰-49吨牵引车头-白/蓝/绿嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 },
|
||
'飞驰-49吨牵引车头-红嘉兴氢能产业发展股份有限公司': { alias: '49T飞驰(嘉氢)', order: 403 }, // merge with above
|
||
'飞驰-49吨牵引车头-红浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-红)', order: 404 },
|
||
'飞驰-49吨牵引车头-白/蓝/绿浙江氢能产业发展有限公司': { alias: '49T飞驰(浙氢-蓝白绿)', order: 405 },
|
||
'楚风-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 },
|
||
};
|
||
|
||
function deriveModelTag(
|
||
brandLabel: string | null,
|
||
modelLabel: string | null,
|
||
color: string | null,
|
||
ownershipLabel: string | null,
|
||
rentCompany: string | null,
|
||
): string {
|
||
const brand = (brandLabel || '').trim();
|
||
const model = (modelLabel || '').trim();
|
||
const c = (color || '').trim();
|
||
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.车辆品牌Label),
|
||
model: deriveModelTag(row.车辆品牌Label, row.车辆型号Label, row.车辆颜色, row.车辆归属状态Label, row.租赁公司),
|
||
color: row.车辆颜色 || '',
|
||
location: region,
|
||
region,
|
||
province: row.省,
|
||
city: row.市,
|
||
status: mapStatus(row.车辆租赁状态Label),
|
||
ownership: mapOwnership(row.车辆租赁状态Label),
|
||
rentCompany: row.租赁公司 || '',
|
||
contractNo: row.合同编码,
|
||
customerName: row.客户名称,
|
||
orgName: row.合同归属公司,
|
||
departmentName: row.合同归属部门,
|
||
subjectOrg: row.主体,
|
||
projectName: row.项目名称,
|
||
customerManager: row.客户经理,
|
||
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;
|
||
}
|
||
|
||
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(id AS CHAR) AS truck_id FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
|
||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
||
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
|
||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_return r
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT CAST(rent_truck.truck_id AS CHAR) AS truck_id FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
||
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
|
||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
||
]);
|
||
|
||
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)`;
|
||
|
||
interface WeeklyStats {
|
||
pendingDelivery: number;
|
||
weeklyNew: number;
|
||
weeklyRemoved: number;
|
||
weeklyDelivered: number;
|
||
weeklyReturned: number;
|
||
weeklyReplaced: number;
|
||
}
|
||
|
||
// 交车单 SQL
|
||
const DELIVERED_SQL = `SELECT
|
||
take.id, DATE(take.handover_date) AS handover_date,
|
||
truck.id AS truck_id, truck.plate_number,
|
||
dic_contract_type.dic_name AS contract_type,
|
||
customer.customer_name
|
||
FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||
LEFT JOIN tab_dic dic_contract_type
|
||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||
AND dic_contract_type.dic_code = contract.contract_type
|
||
AND dic_contract_type.is_deleted = 0
|
||
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||
AND task.task_type = 1 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||
|
||
// 还车单 SQL
|
||
const RETURNED_SQL = `SELECT
|
||
r.id, DATE(r.return_date) AS handover_date,
|
||
truck.id AS truck_id, truck.plate_number,
|
||
dic_contract_type.dic_name AS contract_type,
|
||
customer.customer_name
|
||
FROM tab_truck_rent_return r
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||
LEFT JOIN tab_dic dic_contract_type
|
||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||
AND dic_contract_type.dic_code = contract.contract_type
|
||
AND dic_contract_type.is_deleted = 0
|
||
WHERE r.is_deleted = 0 AND r.return_date IS NOT NULL`;
|
||
|
||
// 替换车单 SQL
|
||
const REPLACED_SQL = `SELECT
|
||
take.id, DATE(take.handover_date) AS handover_date,
|
||
truck.id AS truck_id, truck.plate_number,
|
||
dic_contract_type.dic_name AS contract_type,
|
||
customer.customer_name
|
||
FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
LEFT JOIN tab_contract_rent_truck rent_truck ON rent_truck.id = task.contract_rent_truck_id
|
||
LEFT JOIN tab_truck truck ON rent_truck.truck_id = truck.id
|
||
LEFT JOIN tab_contract contract ON task.contract_id = contract.id
|
||
LEFT JOIN tab_customer customer ON contract.customer_id = customer.id
|
||
LEFT JOIN tab_dic dic_contract_type
|
||
ON dic_contract_type.parent_code = 'dic_contract_type'
|
||
AND dic_contract_type.dic_code = contract.contract_type
|
||
AND dic_contract_type.is_deleted = 0
|
||
WHERE take.is_deleted = 0 AND take.take_name IS NOT NULL
|
||
AND task.task_type = 3 AND task.task_status = 1 AND take.update_time IS NOT NULL`;
|
||
|
||
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 tab_truck WHERE is_deleted=0 AND is_operation=1 AND truck_rent_status=7`),
|
||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE is_deleted=0 AND is_operation=1 AND create_time >= ${WEEK_START_SQL} AND create_time < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck WHERE (is_operation=0 OR is_deleted=1) AND update_time >= ${WEEK_START_SQL} AND update_time < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
||
AND task.task_type=1 AND task.task_status=1 AND take.update_time IS NOT NULL
|
||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = r.truck_rent_task_id
|
||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`),
|
||
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL
|
||
AND task.task_type=3 AND task.task_status=1 AND take.update_time IS NOT NULL
|
||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${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, weekly] = await Promise.all([getVehicles(), getWeeklyStats()]);
|
||
const summary: SummaryData = {
|
||
totalAssets: vehicles.length,
|
||
operating: {
|
||
total: vehicles.filter((v) => v.status === 'Operating').length,
|
||
self: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Self').length,
|
||
leased: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Leased').length,
|
||
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
||
hanging: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Hanging').length,
|
||
},
|
||
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: weekly.weeklyNew,
|
||
weeklyRemoved: weekly.weeklyRemoved,
|
||
weeklyDelivered: weekly.weeklyDelivered,
|
||
weeklyReturned: weekly.weeklyReturned,
|
||
weeklyReplaced: weekly.weeklyReplaced,
|
||
};
|
||
return c.json(summary);
|
||
});
|
||
|
||
// GET /api/vehicles/by-type
|
||
app.get('/by-type', async (c) => {
|
||
const [vehicles, weeklyIds] = await Promise.all([getVehicles(), 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 getVehicles();
|
||
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 = 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 getVehicles();
|
||
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
||
|
||
// Query mileage data: last 30 days attendance & avg mileage per plate
|
||
// + today's mileage for idle detection
|
||
const [[mileageRows], [todayRows]] = await Promise.all([
|
||
pool.query<any[]>(`
|
||
SELECT plateNumber,
|
||
COUNT(CASE WHEN dayMileage > 0 THEN 1 END) AS activeDays,
|
||
COUNT(*) AS totalDays,
|
||
AVG(dayMileage) AS avgMileage
|
||
FROM ln_vehicle_day_mileage
|
||
WHERE dates >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||
GROUP BY plateNumber
|
||
`),
|
||
pool.query<any[]>(`
|
||
SELECT plateNumber, dayMileage
|
||
FROM ln_vehicle_day_mileage
|
||
WHERE dates = CURDATE()
|
||
`),
|
||
]);
|
||
const mileageMap = new Map<string, { activeDays: number; totalDays: number; avgMileage: number }>();
|
||
for (const row of mileageRows as any[]) {
|
||
mileageMap.set(row.plateNumber, {
|
||
activeDays: Number(row.activeDays),
|
||
totalDays: Number(row.totalDays),
|
||
avgMileage: Number(row.avgMileage),
|
||
});
|
||
}
|
||
const todayMileageMap = new Map<string, number>();
|
||
for (const row of todayRows as any[]) {
|
||
todayMileageMap.set(row.plateNumber, Number(row.dayMileage));
|
||
}
|
||
|
||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||
for (const v of withManager) {
|
||
const dept = v.departmentName || '未分配';
|
||
const mgr = v.customerManager || '未分配';
|
||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||
const mgrMap = deptMap.get(dept)!;
|
||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||
mgrMap.get(mgr)!.push(v);
|
||
}
|
||
|
||
// Compute attendance & mileage for a set of vehicles
|
||
const getMileageStats = (vList: Vehicle[]) => {
|
||
let totalActive = 0;
|
||
let totalDays = 0;
|
||
let totalMileage = 0;
|
||
let count = 0;
|
||
for (const v of vList) {
|
||
const m = mileageMap.get(v.plateNumber);
|
||
if (m) {
|
||
totalActive += m.activeDays;
|
||
totalDays += m.totalDays;
|
||
totalMileage += m.avgMileage;
|
||
count++;
|
||
}
|
||
}
|
||
return {
|
||
attendanceRate: totalDays > 0 ? Math.round((totalActive / totalDays) * 1000) / 10 : 0,
|
||
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
|
||
};
|
||
};
|
||
|
||
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
|
||
const allDeptVehicles = Array.from(mgrMap.values()).flat();
|
||
const deptMileage = getMileageStats(allDeptVehicles);
|
||
const managers = Array.from(mgrMap.entries())
|
||
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
|
||
.sort((a, b) => b.total - a.total);
|
||
return {
|
||
department,
|
||
totalAssets: allDeptVehicles.length,
|
||
operatingCount: allDeptVehicles.filter((v) => (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) => {
|
||
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 getVehicles();
|
||
const operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
||
|
||
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[]) =>
|
||
['4.5T', '18T', '49T'].map((type) => {
|
||
const tv = vList.filter((v) => v.type === type);
|
||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
||
}).filter((t) => t.total > 0);
|
||
|
||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||
const result = regionOrder
|
||
.filter((r) => 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 getVehicles();
|
||
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);
|
||
});
|
||
|
||
// 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 getVehicles();
|
||
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department } = c.req.query();
|
||
|
||
let filtered = vehicles;
|
||
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') {
|
||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||
if (macroRegions.includes(location) || location === '其他') {
|
||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||
} else {
|
||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||
const mappedLocation = inventoryRegionMap[location] || location;
|
||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||
}
|
||
}
|
||
if (status && status !== 'All') {
|
||
filtered = filtered.filter((v) => v.status === status);
|
||
}
|
||
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');
|
||
}
|
||
}
|
||
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) => 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,
|
||
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 — grouped inventory stats for the inventory statistics section
|
||
app.get('/inventory-stats', async (c) => {
|
||
const vehicles = await getVehicles();
|
||
const inventory = vehicles.filter((v) => v.status === 'Inventory');
|
||
|
||
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 batch = v.contractNo || 'N/A';
|
||
const key = `${region}|${city}|${brand}|${typeName}|${model}|${batch}`;
|
||
groups.set(key, (groups.get(key) || 0) + 1);
|
||
}
|
||
|
||
const result = Array.from(groups.entries())
|
||
.map(([key, quantity]) => {
|
||
const [region, city, brand, type, model, batch] = key.split('|');
|
||
return { region, city, brand, type, model, batch, quantity };
|
||
})
|
||
.sort((a, b) => b.quantity - a.quantity);
|
||
|
||
return c.json(result);
|
||
});
|
||
|
||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||
app.get('/weekly-detail', async (c) => {
|
||
const type = c.req.query('type');
|
||
let sql: string;
|
||
if (type === 'delivered') {
|
||
sql = `${DELIVERED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||
} else if (type === 'returned') {
|
||
sql = `${RETURNED_SQL} AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL} ORDER BY r.return_date DESC`;
|
||
} else if (type === 'replaced') {
|
||
sql = `${REPLACED_SQL} AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL} ORDER BY take.handover_date DESC`;
|
||
} else if (type === 'pending') {
|
||
sql = `SELECT truck.id AS truck_id, truck.plate_number, NULL AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1 AND truck.truck_rent_status=7`;
|
||
} else if (type === 'new') {
|
||
sql = `SELECT truck.id AS truck_id, truck.plate_number, truck.create_time AS handover_date, NULL AS contract_type, NULL AS customer_name
|
||
FROM tab_truck truck WHERE truck.is_deleted=0 AND truck.is_operation=1
|
||
AND truck.create_time >= ${WEEK_START_SQL} AND truck.create_time < ${WEEK_END_SQL} ORDER BY truck.create_time DESC`;
|
||
} else {
|
||
return c.json([]);
|
||
}
|
||
const [rows] = await pool.query<any[]>(sql);
|
||
return c.json(rows);
|
||
});
|
||
|
||
// GET /api/vehicles/refresh — force cache refresh
|
||
app.get('/refresh', async (c) => {
|
||
lastFetchTime = 0;
|
||
weeklyStatsLastFetch = 0;
|
||
const vehicles = await getVehicles();
|
||
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 tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
|
||
const [[deliveredRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1
|
||
AND take.handover_date >= ${WEEK_START_SQL} AND take.handover_date < ${WEEK_END_SQL}`);
|
||
const [[latestTake]] = await pool.query<any[]>(`SELECT MAX(take.handover_date) AS latest FROM tab_truck_rent_take take
|
||
LEFT JOIN tab_truck_rent_task task ON task.id = take.truck_rent_task_id
|
||
WHERE take.is_deleted=0 AND take.take_name IS NOT NULL AND task.task_type=1 AND task.task_status=1`);
|
||
const [[returnedRecent]] = await pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM tab_truck_rent_return r
|
||
WHERE r.is_deleted=0 AND r.return_date IS NOT NULL
|
||
AND r.return_date >= ${WEEK_START_SQL} AND r.return_date < ${WEEK_END_SQL}`);
|
||
const [[latestReturn]] = await pool.query<any[]>(`SELECT MAX(r.return_date) AS latest FROM tab_truck_rent_return r WHERE r.is_deleted=0 AND r.return_date IS NOT NULL`);
|
||
|
||
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 getVehicles();
|
||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
|
||
const top = Number(c.req.query('top')) || 8;
|
||
|
||
const counts = new Map<string, number>();
|
||
for (const v of operating) {
|
||
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
|
||
counts.set(key, (counts.get(key) || 0) + 1);
|
||
}
|
||
|
||
// 分离"其他",对非"其他"排序取 Top N,剩余全部合入"其他"
|
||
const otherCount = counts.get('其他') || 0;
|
||
counts.delete('其他');
|
||
|
||
const sorted = Array.from(counts.entries())
|
||
.map(([name, value]) => ({ name, value }))
|
||
.sort((a, b) => b.value - a.value);
|
||
|
||
const result = sorted.slice(0, top);
|
||
const restTotal = sorted.slice(top).reduce((s, item) => s + item.value, 0) + otherCount;
|
||
if (restTotal > 0) result.push({ name: '其他', value: restTotal });
|
||
return c.json(result);
|
||
});
|
||
|
||
export default app;
|