|
|
|
@@ -0,0 +1,613 @@
|
|
|
|
|
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
|
|
|
|
|
truck.id 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 '其它';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map rental status to frontend status
|
|
|
|
|
// Actual DB values: 在库(0), 自营(1), 租赁(2), 待交车(7), 挂靠(8), 异动(12)
|
|
|
|
|
function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Abnormal' {
|
|
|
|
|
if (!rentStatus) return 'Inventory';
|
|
|
|
|
const s = rentStatus.trim();
|
|
|
|
|
if (s === '租赁' || s === '自营' || s === '挂靠') return 'Operating';
|
|
|
|
|
if (s === '在库' || s === '待交车') return 'Inventory';
|
|
|
|
|
if (s === '异动') return 'Abnormal';
|
|
|
|
|
return 'Inventory';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Map ownership status
|
|
|
|
|
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
|
|
|
|
|
function mapOwnership(ascriptionLabel: string | null): string {
|
|
|
|
|
if (!ascriptionLabel) return 'Self';
|
|
|
|
|
const s = ascriptionLabel.trim();
|
|
|
|
|
if (s === '自有') return 'Self';
|
|
|
|
|
if (s === '外租') return 'Leased';
|
|
|
|
|
if (s === '挂靠') return 'Hanging';
|
|
|
|
|
return 'Self';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
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>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStats(list: Vehicle[]) {
|
|
|
|
|
return {
|
|
|
|
|
total: list.length,
|
|
|
|
|
inventory: list.filter((v) => v.status === 'Inventory').length,
|
|
|
|
|
inventoryRegions: getRegionCounts(
|
|
|
|
|
list.filter((v) => v.status === 'Inventory'),
|
|
|
|
|
REGIONS,
|
|
|
|
|
),
|
|
|
|
|
pending: 0,
|
|
|
|
|
operating: list.filter((v) => v.status === 'Operating').length,
|
|
|
|
|
weeklyDelivered: 0,
|
|
|
|
|
weeklyReturned: 0,
|
|
|
|
|
weeklyReplaced: 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 (${DELIVERED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
|
|
|
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${RETURNED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.handover_date < ${WEEK_END_SQL}`),
|
|
|
|
|
pool.query<any[]>(`SELECT COUNT(*) AS cnt FROM (${REPLACED_SQL}) t WHERE t.handover_date >= ${WEEK_START_SQL} AND t.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').length,
|
|
|
|
|
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
|
|
|
|
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
|
|
|
|
|
},
|
|
|
|
|
...weekly,
|
|
|
|
|
};
|
|
|
|
|
return c.json(summary);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /api/vehicles/by-type
|
|
|
|
|
app.get('/by-type', 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: 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);
|
|
|
|
|
// Use contractNo as batch identifier
|
|
|
|
|
const batches = Array.from(new Set(modelVehicles.map((v) => v.contractNo || '未知'))).filter(Boolean);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
model,
|
|
|
|
|
...getStats(modelVehicles),
|
|
|
|
|
batches: batches.map((batch) => ({
|
|
|
|
|
batch,
|
|
|
|
|
...getStats(modelVehicles.filter((v) => (v.contractNo || '未知') === batch)),
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const typeStats = getStats(typeVehicles);
|
|
|
|
|
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/list — flat list with optional filters
|
|
|
|
|
app.get('/list', async (c) => {
|
|
|
|
|
const vehicles = await getVehicles();
|
|
|
|
|
const { batch, model, location, status, category } = c.req.query();
|
|
|
|
|
|
|
|
|
|
let filtered = vehicles;
|
|
|
|
|
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 both display region names and inventory region names
|
|
|
|
|
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
|
|
|
|
const mappedLocation = inventoryRegionMap[location] || location;
|
|
|
|
|
filtered = filtered.filter((v) => v.location === mappedLocation);
|
|
|
|
|
}
|
|
|
|
|
if (status && status !== 'All') {
|
|
|
|
|
filtered = filtered.filter((v) => v.status === status);
|
|
|
|
|
}
|
|
|
|
|
if (category) {
|
|
|
|
|
if (category === 'Inventory') {
|
|
|
|
|
filtered = filtered.filter((v) => v.status === 'Inventory');
|
|
|
|
|
} else if (category === 'Operating') {
|
|
|
|
|
filtered = filtered.filter((v) => v.status === 'Operating');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.json(
|
|
|
|
|
filtered.map((v) => ({
|
|
|
|
|
id: v.id,
|
|
|
|
|
plateNumber: v.plateNumber,
|
|
|
|
|
type: v.type,
|
|
|
|
|
model: v.model,
|
|
|
|
|
location: v.location,
|
|
|
|
|
status: v.status,
|
|
|
|
|
ownership: v.ownership,
|
|
|
|
|
contractNo: v.contractNo,
|
|
|
|
|
customerName: v.customerName,
|
|
|
|
|
subjectOrg: v.subjectOrg,
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
|
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;
|
|
|
|
|
const vehicles = await getVehicles();
|
|
|
|
|
return c.json({ message: 'Cache refreshed', count: vehicles.length });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default app;
|