refactor: modularize application domains
This commit is contained in:
+42
-797
@@ -1,7 +1,5 @@
|
||||
import { Hono } from 'hono';
|
||||
import pool from '../db.js';
|
||||
import type {
|
||||
VehicleRow,
|
||||
Vehicle,
|
||||
SummaryData,
|
||||
TypeSummary,
|
||||
@@ -13,362 +11,33 @@ import type {
|
||||
import { filterByPermission, maskCustomerNames, maskCustomerName } from '../auth/permissions.js';
|
||||
import type { AuthUser } from '../auth/types.js';
|
||||
import type { Context } from 'hono';
|
||||
import {
|
||||
INVENTORY_REGIONS,
|
||||
SUMMARY_TYPE_FILTERS,
|
||||
VEHICLE_TYPE_FILTERS,
|
||||
classifyVehicleType,
|
||||
compareModelNames,
|
||||
filterByLocation,
|
||||
mapInventoryRegion,
|
||||
mapMacroRegion,
|
||||
resolveCity,
|
||||
} from './vehicles/model.js';
|
||||
import {
|
||||
compareDepartmentNames,
|
||||
countByType,
|
||||
getMileageStats,
|
||||
getStats,
|
||||
listDateRange,
|
||||
normalizeDateRange,
|
||||
} from './vehicles/utils.js';
|
||||
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './vehicles/repository.js';
|
||||
|
||||
export { mapRegion } from './vehicles/model.js';
|
||||
|
||||
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 all = await vehicleRepository.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);
|
||||
@@ -387,239 +56,9 @@ function applySubjectFilter(c: Context, vehicles: Vehicle[]): Vehicle[] {
|
||||
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 [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.getWeeklyTruckIds()]);
|
||||
const vehicleIds = new Set(vehicles.map(v => String(v.id)));
|
||||
const summary: SummaryData = {
|
||||
totalAssets: vehicles.length,
|
||||
@@ -647,17 +86,9 @@ app.get('/summary', async (c) => {
|
||||
|
||||
// GET /api/vehicles/by-type
|
||||
app.get('/by-type', async (c) => {
|
||||
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), getWeeklyTruckIds()]);
|
||||
const [vehicles, weeklyIds] = await Promise.all([getVehiclesForUser(c), vehicleRepository.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 result: TypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
|
||||
const typeVehicles = vehicles.filter(t.filter);
|
||||
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||
|
||||
@@ -686,7 +117,7 @@ app.get('/by-type', async (c) => {
|
||||
weeklyDelivered: typeStats.weeklyDelivered,
|
||||
weeklyReturned: typeStats.weeklyReturned,
|
||||
weeklyReplaced: typeStats.weeklyReplaced,
|
||||
models: modelSummaries.sort((a, b) => getModelOrder(a.model) - getModelOrder(b.model)),
|
||||
models: modelSummaries.sort((a, b) => compareModelNames(a.model, b.model)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -724,17 +155,9 @@ app.get('/by-batch', async (c) => {
|
||||
|
||||
// GET /api/vehicles/inventory-analysis — 库存分析,不设数据权限,对所有人开放
|
||||
app.get('/inventory-analysis', async (c) => {
|
||||
const vehicles = applySubjectFilter(c, await getVehicles());
|
||||
const vehicles = applySubjectFilter(c, await vehicleRepository.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 result: InventoryTypeSummary[] = SUMMARY_TYPE_FILTERS.map((t) => {
|
||||
const typeVehicles = vehicles.filter(t.filter);
|
||||
const models = Array.from(new Set(typeVehicles.map((v) => v.model)));
|
||||
|
||||
@@ -782,11 +205,7 @@ app.get('/dept-stats', async (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 realtimeRows = await vehicleRepository.getRealtimeMileageRows();
|
||||
const todayMileageMap = new Map<string, number>();
|
||||
for (const row of realtimeRows as any[]) {
|
||||
const plate = (row.plate_number || '').trim();
|
||||
@@ -808,27 +227,9 @@ app.get('/dept-stats', async (c) => {
|
||||
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 deptMileage = getMileageStats(allDeptVehicles, todayMileageMap);
|
||||
const managers = Array.from(mgrMap.entries())
|
||||
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
@@ -841,16 +242,7 @@ app.get('/dept-stats', async (c) => {
|
||||
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);
|
||||
});
|
||||
}).sort((a, b) => compareDepartmentNames(a.department, b.department));
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -971,31 +363,6 @@ app.get('/customer-stats', async (c) => {
|
||||
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);
|
||||
@@ -1005,7 +372,7 @@ app.get('/list', async (c) => {
|
||||
|
||||
// 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 realtimeRows = await vehicleRepository.getAttendanceMileageRows();
|
||||
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');
|
||||
@@ -1093,7 +460,7 @@ app.get('/list', async (c) => {
|
||||
|
||||
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
|
||||
app.get('/inventory-stats', async (c) => {
|
||||
const vehicles = applySubjectFilter(c, await getVehicles());
|
||||
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
|
||||
|
||||
const TYPE_NAME_MAP: Record<string, string> = {
|
||||
@@ -1133,29 +500,11 @@ 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 {
|
||||
const validTypes: WeeklyDetailType[] = ['delivered', 'returned', 'replaced', 'pending', 'new'];
|
||||
if (!validTypes.includes(type as WeeklyDetailType)) {
|
||||
return c.json([]);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>(sql);
|
||||
let result = rows as any[];
|
||||
let result = await vehicleRepository.getWeeklyDetailRows(type as WeeklyDetailType);
|
||||
|
||||
// 按型号/批次/区域过滤:借助缓存车辆集,取 truck_id 交集
|
||||
const hasModelFilter = model && model !== 'All';
|
||||
@@ -1182,95 +531,8 @@ app.get('/flow-stats', async (c) => {
|
||||
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)
|
||||
const rows = await vehicleRepository.getFlowDetailRows(start, end);
|
||||
|
||||
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) => ({
|
||||
@@ -1312,7 +574,7 @@ app.get('/flow-stats', async (c) => {
|
||||
|
||||
// GET /api/vehicles/subjects — 归属公司列表(含台数预览),用于顶部筛选下拉
|
||||
app.get('/subjects', async (c) => {
|
||||
const all = await getVehicles();
|
||||
const all = await vehicleRepository.getVehicles();
|
||||
const user = ((c as any).get?.('user') || (c as any).var?.user) as AuthUser | undefined;
|
||||
const visible = user ? filterByPermission(all, user) : all;
|
||||
|
||||
@@ -1336,31 +598,14 @@ app.get('/subjects', async (c) => {
|
||||
|
||||
// GET /api/vehicles/refresh — force cache refresh
|
||||
app.get('/refresh', async (c) => {
|
||||
lastFetchTime = 0;
|
||||
weeklyStatsLastFetch = 0;
|
||||
vehicleRepository.invalidateRefreshCaches();
|
||||
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)`);
|
||||
const { dateRange, deliveredAll, deliveredRecent, latestTake, returnedRecent, latestReturn } = await vehicleRepository.getWeeklyDebugData();
|
||||
|
||||
return c.json({
|
||||
weekRange: dateRange,
|
||||
@@ -1388,7 +633,7 @@ app.get('/region-chart', async (c) => {
|
||||
}
|
||||
} 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 rows = await vehicleRepository.getRealtimeProvinceRows();
|
||||
const plateProvince = new Map<string, string>();
|
||||
for (const row of rows as any[]) {
|
||||
const plate = (row.plate_number || '').trim();
|
||||
|
||||
Reference in New Issue
Block a user