refactor: modularize application domains
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import {
|
||||
SUMMARY_TYPE_FILTERS,
|
||||
VEHICLE_TYPE_FILTERS,
|
||||
classifyVehicleType,
|
||||
compareModelNames,
|
||||
deriveModelTag,
|
||||
deriveType,
|
||||
filterByLocation,
|
||||
mapInventoryRegion,
|
||||
mapMacroRegion,
|
||||
mapOwnership,
|
||||
mapRegion,
|
||||
mapStatus,
|
||||
resolveCity,
|
||||
transformRow,
|
||||
} from './model.js';
|
||||
import {
|
||||
addDateDays,
|
||||
compareDepartmentNames,
|
||||
countByType,
|
||||
getMileageStats,
|
||||
getStats,
|
||||
isDateParam,
|
||||
listDateRange,
|
||||
normalizeDateRange,
|
||||
} from './utils.js';
|
||||
|
||||
function vehicle(overrides: Partial<Vehicle> = {}): Vehicle {
|
||||
return {
|
||||
id: '1',
|
||||
plateNumber: '粤A00001',
|
||||
vin: 'VIN-1',
|
||||
type: '4.5T',
|
||||
model: '现代4.5T普货',
|
||||
color: '白色',
|
||||
location: '广东',
|
||||
region: '广东',
|
||||
province: '广东省',
|
||||
city: '广州市',
|
||||
status: 'Operating',
|
||||
operationStatus: '2',
|
||||
ownership: 'Self',
|
||||
rentCompany: '',
|
||||
contractNo: null,
|
||||
customerName: null,
|
||||
orgName: null,
|
||||
departmentName: null,
|
||||
subjectOrg: null,
|
||||
projectName: null,
|
||||
customerManager: null,
|
||||
managerId: null,
|
||||
brandLabel: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('preserves display, inventory and macro region mappings', () => {
|
||||
assert.equal(mapRegion(null, null), '其他');
|
||||
assert.equal(mapRegion('广东省', '杭州市'), '广东');
|
||||
assert.equal(mapRegion('广东省', '浙江省'), '嘉兴');
|
||||
assert.equal(mapRegion('广东省', '未知市'), '广东');
|
||||
assert.equal(mapRegion(null, '乌鲁木齐市'), '新疆');
|
||||
assert.equal(mapInventoryRegion('嘉兴'), '江浙沪');
|
||||
assert.equal(mapInventoryRegion('北京'), '其它');
|
||||
assert.equal(mapMacroRegion('山东省', '青岛市'), '华东');
|
||||
assert.equal(mapMacroRegion(null, '成都市'), '西南');
|
||||
assert.equal(mapMacroRegion(null, null), '其他');
|
||||
assert.equal(resolveCity('[北京市]', null), '北京市');
|
||||
assert.equal(resolveCity(null, '[上海市]'), '上海市');
|
||||
});
|
||||
|
||||
test('keeps vehicle status precedence and ownership aliases', () => {
|
||||
assert.equal(mapStatus('2', '4'), 'Pending');
|
||||
assert.equal(mapStatus('1', '14'), 'Abnormal');
|
||||
assert.equal(mapStatus('租赁', null), 'Operating');
|
||||
assert.equal(mapStatus('待运营', null), 'Inventory');
|
||||
assert.equal(mapStatus(null, null), 'Inventory');
|
||||
assert.equal(mapOwnership('0'), 'Self');
|
||||
assert.equal(mapOwnership('1'), 'Hanging');
|
||||
assert.equal(mapOwnership('外租'), 'Leased');
|
||||
assert.equal(mapOwnership(null), 'Unknown');
|
||||
});
|
||||
|
||||
test('preserves type precedence, aliases and model sorting', () => {
|
||||
assert.equal(deriveType('重型集装箱半挂车', '1'), '挂车');
|
||||
assert.equal(deriveType('4.5吨货车', '3'), '4.5T');
|
||||
assert.equal(deriveType('氢能叉车', null), '叉车');
|
||||
assert.equal(deriveModelTag('现代', '4.5吨货车', '白色', '自有', '忽略的公司'), '现代4.5T普货');
|
||||
assert.equal(
|
||||
deriveModelTag('现代', '4.5吨货车', '白色', '外租', '广州开发区交投氢能运营管理有限公司'),
|
||||
'现代4.5T普货(交投)',
|
||||
);
|
||||
assert.equal(deriveModelTag(null, null, null, null, null), '未知车型');
|
||||
|
||||
const models = ['未知车型', '49T宇通', '现代4.5T普货(恒运)', '现代4.5T普货'];
|
||||
assert.deepEqual(models.sort(compareModelNames), [
|
||||
'现代4.5T普货',
|
||||
'现代4.5T普货(恒运)',
|
||||
'49T宇通',
|
||||
'未知车型',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps summary and list meanings of other vehicle types distinct', () => {
|
||||
const trailer = vehicle({ type: '挂车', model: '挂车' });
|
||||
const forklift = vehicle({ type: '叉车', model: '氢能叉车' });
|
||||
const coldChain = vehicle({ type: '4.5T', model: '现代4.5T冷链(羚牛)' });
|
||||
const summaryOther = SUMMARY_TYPE_FILTERS.find((item) => item.name === '其他');
|
||||
|
||||
assert.equal(classifyVehicleType(trailer), 'trailer');
|
||||
assert.equal(classifyVehicleType(forklift), 'other');
|
||||
assert.equal(classifyVehicleType(coldChain), 't4_5c');
|
||||
assert.equal(summaryOther?.filter(trailer), true);
|
||||
assert.equal(VEHICLE_TYPE_FILTERS['其他'](trailer), false);
|
||||
assert.equal(VEHICLE_TYPE_FILTERS['其他'](forklift), true);
|
||||
});
|
||||
|
||||
test('keeps location filtering semantics for the ambiguous other region', () => {
|
||||
const assetOtherInGuangdong = vehicle({ id: 'asset-other', location: '其他', province: '广东省', city: '广州市' });
|
||||
const macroOther = vehicle({ id: 'macro-other', location: '其他', province: null, city: null });
|
||||
const jiaxing = vehicle({ id: 'jiaxing', location: '嘉兴', province: '浙江省', city: '嘉兴市' });
|
||||
const vehicles = [assetOtherInGuangdong, macroOther, jiaxing];
|
||||
|
||||
assert.deepEqual(filterByLocation(vehicles, '其他', 'asset').map((item) => item.id), ['asset-other', 'macro-other']);
|
||||
assert.deepEqual(filterByLocation(vehicles, '其他').map((item) => item.id), ['macro-other']);
|
||||
assert.deepEqual(filterByLocation(vehicles, '江浙沪').map((item) => item.id), ['jiaxing']);
|
||||
});
|
||||
|
||||
test('transforms database rows without changing the route model contract', () => {
|
||||
const row: VehicleRow = {
|
||||
id: '88',
|
||||
车牌号: '粤A00088',
|
||||
vin: 'VIN-88',
|
||||
车辆品牌: 'hyundai',
|
||||
车辆型号: 'raw-model',
|
||||
车辆颜色: '白色',
|
||||
租赁公司: '',
|
||||
车辆归属状态Label: '自有',
|
||||
车辆型号Label: '4.5吨货车',
|
||||
车辆类型参数: '1',
|
||||
库存区域: null,
|
||||
车辆租赁状态: null,
|
||||
车辆租赁状态Label: '自营',
|
||||
是否营运: 1,
|
||||
省: '广东省',
|
||||
市: '广州市',
|
||||
纬度: null,
|
||||
经度: null,
|
||||
车辆品牌Label: '现代',
|
||||
合同ID: null,
|
||||
合同编码: 'HT-1',
|
||||
客户名称: '客户',
|
||||
合同归属公司: '公司',
|
||||
合同归属部门: '业务一部',
|
||||
主体: '主体',
|
||||
项目名称: '项目',
|
||||
客户经理: '经理',
|
||||
经理ID: '9',
|
||||
};
|
||||
|
||||
assert.deepEqual(transformRow(row), {
|
||||
id: '88',
|
||||
plateNumber: '粤A00088',
|
||||
vin: 'VIN-88',
|
||||
type: '4.5T',
|
||||
model: '现代4.5T普货',
|
||||
color: '白色',
|
||||
location: '广东',
|
||||
region: '广东',
|
||||
province: '广东省',
|
||||
city: '广州市',
|
||||
status: 'Operating',
|
||||
operationStatus: '自营',
|
||||
ownership: 'Self',
|
||||
rentCompany: '',
|
||||
contractNo: 'HT-1',
|
||||
customerName: '客户',
|
||||
orgName: '公司',
|
||||
departmentName: '业务一部',
|
||||
subjectOrg: '主体',
|
||||
projectName: '项目',
|
||||
customerManager: '经理',
|
||||
managerId: '9',
|
||||
brandLabel: '现代',
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves inventory, weekly-flow and mileage aggregation boundaries', () => {
|
||||
const vehicles = [
|
||||
vehicle({ id: '1', plateNumber: 'A', status: 'Operating', type: '4.5T', model: '普货' }),
|
||||
vehicle({ id: '2', plateNumber: 'B', status: 'Inventory', location: '嘉兴', type: '4.5T', model: '冷链' }),
|
||||
vehicle({ id: '3', plateNumber: 'C', status: 'Abnormal', location: '北京', type: '挂车', model: '挂车' }),
|
||||
vehicle({ id: '4', plateNumber: 'D', status: 'Pending', type: '叉车', model: '叉车' }),
|
||||
];
|
||||
const weeklyIds = {
|
||||
pending: new Set<string>(),
|
||||
delivered: new Set(['1', '9']),
|
||||
returned: new Set(['2']),
|
||||
replaced: new Set(['3']),
|
||||
};
|
||||
|
||||
assert.deepEqual(countByType(vehicles), {
|
||||
t4_5: 1,
|
||||
t4_5c: 1,
|
||||
t18: 0,
|
||||
t49: 0,
|
||||
trailer: 1,
|
||||
other: 1,
|
||||
total: 4,
|
||||
});
|
||||
assert.deepEqual(getStats(vehicles, weeklyIds), {
|
||||
total: 4,
|
||||
inventory: 2,
|
||||
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 },
|
||||
pending: 1,
|
||||
operating: 1,
|
||||
weeklyDelivered: 1,
|
||||
weeklyReturned: 1,
|
||||
weeklyReplaced: 1,
|
||||
});
|
||||
assert.deepEqual(getMileageStats(vehicles, new Map([['A', 100.4], ['B', 0], ['C', -5], ['D', 200.4]])), {
|
||||
attendanceRate: 50,
|
||||
avgMileage: 150,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves date validation, inclusive ranges, caps and department sorting', () => {
|
||||
assert.equal(isDateParam('2026-08-13'), true);
|
||||
assert.equal(isDateParam('2026-13-99'), true);
|
||||
assert.equal(isDateParam('2026/08/13'), false);
|
||||
assert.equal(addDateDays('2024-02-28', 1), '2024-02-29');
|
||||
assert.deepEqual(listDateRange('2026-08-11', '2026-08-13'), ['2026-08-11', '2026-08-12', '2026-08-13']);
|
||||
assert.deepEqual(normalizeDateRange(undefined, undefined, new Date(2026, 7, 13, 12)), {
|
||||
start: '2026-07-15',
|
||||
end: '2026-08-13',
|
||||
});
|
||||
assert.deepEqual(normalizeDateRange('2026-08-10', '2026-08-01'), {
|
||||
start: '2026-08-01',
|
||||
end: '2026-08-10',
|
||||
});
|
||||
const capped = normalizeDateRange('2020-01-01', '2026-08-13');
|
||||
assert.equal(listDateRange(capped.start, capped.end).length, 370);
|
||||
|
||||
const departments = ['公务车', '未分配部门', '业务十部', '业务二部', '业务一部'];
|
||||
assert.deepEqual(departments.sort(compareDepartmentNames), ['业务一部', '业务二部', '业务十部', '未分配部门', '公务车']);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import type { VehicleTypeCategory } from './types.js';
|
||||
|
||||
export const REGIONS = ['嘉兴', '广东', '北京', '新疆', '其他'] as const;
|
||||
export 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 '新疆';
|
||||
|
||||
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 '其他';
|
||||
}
|
||||
|
||||
export function mapInventoryRegion(region: string): string {
|
||||
if (region === '嘉兴') return '江浙沪';
|
||||
if (region === '广东') return '广东';
|
||||
if (region === '新疆') return '新疆';
|
||||
return '其它';
|
||||
}
|
||||
|
||||
export function mapMacroRegion(province: string | null, city: string | null): string {
|
||||
const prov = (province || '').trim();
|
||||
const cityName = (city || '').trim();
|
||||
const location = prov + cityName;
|
||||
if (/上海|江苏|浙江|安徽|福建|江西|山东|南京|杭州|合肥|济南|青岛|苏州|宁波|厦门|嘉兴|无锡/.test(location)) return '华东';
|
||||
if (/广东|广西|海南|广州|深圳|佛山|东莞|珠海|惠州|中山|南宁/.test(location)) return '华南';
|
||||
if (/北京|天津|河北|山西|内蒙古|石家庄|太原|呼和浩特/.test(location)) return '华北';
|
||||
if (/河南|湖北|湖南|郑州|武汉|长沙/.test(location)) return '华中';
|
||||
if (/重庆|四川|贵州|云南|西藏|成都|昆明|贵阳/.test(location)) return '西南';
|
||||
if (/陕西|甘肃|青海|宁夏|新疆|西安|兰州|乌鲁木齐/.test(location)) return '西北';
|
||||
return '其他';
|
||||
}
|
||||
|
||||
export function classifyVehicleType(vehicle: Vehicle): VehicleTypeCategory {
|
||||
if (vehicle.type === '4.5T' && !vehicle.model.includes('冷链')) return 't4_5';
|
||||
if (vehicle.type === '4.5T' && vehicle.model.includes('冷链')) return 't4_5c';
|
||||
if (vehicle.type === '18T') return 't18';
|
||||
if (vehicle.type === '49T') return 't49';
|
||||
if (vehicle.type === '挂车' || vehicle.model.includes('挂车')) return 'trailer';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// vehicle_status has priority because pending (4) and abnormal (14) override operation_status.
|
||||
// operation_status: 1=租赁, 2=自营, 3=可运营, 4=待运营, 5=退出运营.
|
||||
export function mapStatus(operationStatus: string | null, vehicleStatus: string | null): Vehicle['status'] {
|
||||
const operation = (operationStatus || '').trim();
|
||||
const vehicle = (vehicleStatus || '').trim();
|
||||
if (vehicle === '4') return 'Pending';
|
||||
if (vehicle === '14') return 'Abnormal';
|
||||
if (operation === '1' || operation === '2') return 'Operating';
|
||||
if (operation === '3' || operation === '4') return 'Inventory';
|
||||
if (operation === '租赁' || operation === '自营') return 'Operating';
|
||||
if (operation === '可运营' || operation === '待运营' || operation === '在库') return 'Inventory';
|
||||
if (operation === '异动') return 'Abnormal';
|
||||
return 'Inventory';
|
||||
}
|
||||
|
||||
export function mapOwnership(rentStatusLabel: string | null): string {
|
||||
if (!rentStatusLabel) return 'Unknown';
|
||||
const status = rentStatusLabel.trim();
|
||||
if (status === '0') return 'Self';
|
||||
if (status === '1') return 'Hanging';
|
||||
if (status === '2') return 'Leased';
|
||||
if (status === '3') return 'Self';
|
||||
if (status === '自有') return 'Self';
|
||||
if (status === '外租') return 'Leased';
|
||||
if (status === '自营') return 'Self';
|
||||
if (status === '租赁') return 'Leased';
|
||||
if (status === '挂靠') return 'Hanging';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
export function resolveCity(city: string | null, province: string | null): string {
|
||||
const cityName = (city || '').replace(/[\[\]]/g, '').trim();
|
||||
if (cityName) return cityName;
|
||||
const provinceName = (province || '').replace(/[\[\]]/g, '').trim();
|
||||
return provinceName || '其他';
|
||||
}
|
||||
|
||||
// vehicle_type is the new-system category code: 1=4.5T, 3=牵引车头, 5/6=挂车.
|
||||
export 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 '其他车型';
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
// Key format: brand-model-color[+rentCompany when ownership is 外租].
|
||||
// Multiple source tags intentionally share aliases and order values to merge dirty or equivalent data.
|
||||
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 },
|
||||
'苏龙-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 },
|
||||
'飞驰-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 },
|
||||
};
|
||||
|
||||
export 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 vehicleColor = (color || '').trim();
|
||||
if (model === '公务用车/小客车') return '公务车/挂靠车';
|
||||
const company = ownershipLabel?.trim() === '外租' ? (rentCompany || '').trim() : '';
|
||||
|
||||
if (!brand && !model) return '未知车型';
|
||||
const tag = `${brand}-${model}-${vehicleColor}${company}`;
|
||||
return MODEL_ALIAS_MAP[tag]?.alias || tag;
|
||||
}
|
||||
|
||||
export function getModelOrder(model: string): number {
|
||||
for (const entry of Object.values(MODEL_ALIAS_MAP)) {
|
||||
if (entry.alias === model) return entry.order;
|
||||
}
|
||||
return 999;
|
||||
}
|
||||
|
||||
export function compareModelNames(left: string, right: string): number {
|
||||
return getModelOrder(left) - getModelOrder(right);
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export const SUMMARY_TYPE_FILTERS: ReadonlyArray<{ name: string; filter: (vehicle: Vehicle) => boolean }> = [
|
||||
{ name: '4.5T普货', filter: (vehicle) => vehicle.type === '4.5T' && !vehicle.model.includes('冷链') },
|
||||
{ name: '4.5T冷链', filter: (vehicle) => vehicle.type === '4.5T' && vehicle.model.includes('冷链') },
|
||||
{ name: '18T', filter: (vehicle) => vehicle.type === '18T' },
|
||||
{ name: '49T', filter: (vehicle) => vehicle.type === '49T' },
|
||||
{ name: '其他', filter: (vehicle) => !['4.5T', '18T', '49T'].includes(vehicle.type) },
|
||||
];
|
||||
|
||||
// List filtering keeps trailers separate, while summary grouping intentionally includes them in “其他”.
|
||||
export const VEHICLE_TYPE_FILTERS: Record<string, (vehicle: Vehicle) => boolean> = {
|
||||
'4.5T普货': (vehicle) => vehicle.type === '4.5T' && !vehicle.model.includes('冷链'),
|
||||
'4.5T冷链': (vehicle) => vehicle.type === '4.5T' && vehicle.model.includes('冷链'),
|
||||
'18T': (vehicle) => vehicle.type === '18T',
|
||||
'49T': (vehicle) => vehicle.type === '49T',
|
||||
'挂车': (vehicle) => vehicle.type === '挂车' || vehicle.model.includes('挂车'),
|
||||
'其他': (vehicle) => classifyVehicleType(vehicle) === 'other',
|
||||
};
|
||||
|
||||
export function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] {
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
const isMacro = macroRegions.includes(location) || (location === '其他' && source !== 'asset');
|
||||
if (isMacro) {
|
||||
return vehicles.filter((vehicle) => mapMacroRegion(vehicle.province, vehicle.city) === location);
|
||||
}
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
return vehicles.filter(
|
||||
(vehicle) => vehicle.location === mappedLocation
|
||||
|| vehicle.city === location
|
||||
|| resolveCity(vehicle.city, vehicle.province) === location,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import {
|
||||
FLOW_STATS_SQL,
|
||||
MAIN_SQL,
|
||||
VehicleRepository,
|
||||
WEEKLY_DETAIL_SQL,
|
||||
WEEKLY_TRUCK_ID_SQLS,
|
||||
} from './repository.js';
|
||||
|
||||
class FakeDatabase {
|
||||
readonly calls: Array<{ sql: string; values?: any[] }> = [];
|
||||
|
||||
constructor(private readonly responses: any[][]) {}
|
||||
|
||||
async query<T = any[]>(sql: string, values?: any[]): Promise<[T, ...any[]]> {
|
||||
this.calls.push({ sql, values });
|
||||
return [this.responses.shift() as T];
|
||||
}
|
||||
}
|
||||
|
||||
function vehicle(id: string): Vehicle {
|
||||
return { id } as Vehicle;
|
||||
}
|
||||
|
||||
test('keeps all vehicle GET endpoints in their established order', () => {
|
||||
const source = readFileSync(new URL('../vehicles.ts', import.meta.url), 'utf8');
|
||||
const paths = Array.from(source.matchAll(/app\.get\('([^']+)'/g), (match) => match[1]);
|
||||
|
||||
assert.deepEqual(paths, [
|
||||
'/summary',
|
||||
'/by-type',
|
||||
'/by-batch',
|
||||
'/inventory-analysis',
|
||||
'/dept-stats',
|
||||
'/region-stats',
|
||||
'/customer-stats',
|
||||
'/list',
|
||||
'/inventory-stats',
|
||||
'/weekly-detail',
|
||||
'/flow-stats',
|
||||
'/subjects',
|
||||
'/refresh',
|
||||
'/debug',
|
||||
'/region-chart',
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bSELECT\b|pool\.query|MAIN_SQL/);
|
||||
});
|
||||
|
||||
test('keeps the non-empty vehicle cache for less than 60 seconds', async () => {
|
||||
let now = 100_000;
|
||||
const db = new FakeDatabase([[{ id: 'first' }], [{ id: 'second' }]]);
|
||||
const repository = new VehicleRepository(
|
||||
db,
|
||||
() => now,
|
||||
(row: VehicleRow) => vehicle(row.id),
|
||||
);
|
||||
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
||||
now += 59_999;
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
||||
now += 1;
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
|
||||
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, MAIN_SQL]);
|
||||
});
|
||||
|
||||
test('keeps empty vehicle results uncached', async () => {
|
||||
const db = new FakeDatabase([[], []]);
|
||||
const repository = new VehicleRepository(db, () => 100_000);
|
||||
|
||||
await repository.getVehicles();
|
||||
await repository.getVehicles();
|
||||
|
||||
assert.equal(db.calls.length, 2);
|
||||
});
|
||||
|
||||
test('refresh invalidates vehicles but retains the weekly truck-id cache', async () => {
|
||||
const db = new FakeDatabase([
|
||||
[{ id: 'first' }],
|
||||
[{ truck_id: 'pending' }],
|
||||
[{ truck_id: 'delivered' }],
|
||||
[{ truck_id: 'returned' }],
|
||||
[{ truck_id: 'replaced' }, { truck_id: null }],
|
||||
[{ id: 'second' }],
|
||||
]);
|
||||
const repository = new VehicleRepository(
|
||||
db,
|
||||
() => 100_000,
|
||||
(row: VehicleRow) => vehicle(row.id),
|
||||
);
|
||||
|
||||
await repository.getVehicles();
|
||||
const weeklyBefore = await repository.getWeeklyTruckIds();
|
||||
repository.invalidateRefreshCaches();
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
|
||||
assert.equal(await repository.getWeeklyTruckIds(), weeklyBefore);
|
||||
assert.deepEqual(db.calls.map((call) => call.sql), [MAIN_SQL, ...WEEKLY_TRUCK_ID_SQLS, MAIN_SQL]);
|
||||
assert.deepEqual([...weeklyBefore.replaced], ['replaced']);
|
||||
});
|
||||
|
||||
test('keeps weekly detail SQL selection and flow date parameters unchanged', async () => {
|
||||
const db = new FakeDatabase([[{ truck_id: '1' }], [{ id: 'delivered-1' }]]);
|
||||
const repository = new VehicleRepository(db);
|
||||
|
||||
assert.deepEqual(await repository.getWeeklyDetailRows('new'), [{ truck_id: '1' }]);
|
||||
assert.deepEqual(await repository.getFlowDetailRows('2026-08-01', '2026-08-13'), [{ id: 'delivered-1' }]);
|
||||
assert.deepEqual(db.calls, [
|
||||
{ sql: WEEKLY_DETAIL_SQL.new, values: undefined },
|
||||
{
|
||||
sql: FLOW_STATS_SQL,
|
||||
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import pool from '../../db.js';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import { transformRow } from './model.js';
|
||||
import type { WeeklyTruckIds } from './types.js';
|
||||
|
||||
interface Database {
|
||||
query<T = any[]>(sql: string, values?: any[]): Promise<[T, ...any[]]>;
|
||||
}
|
||||
|
||||
type FlowType = 'delivered' | 'returned' | 'replaced';
|
||||
|
||||
export type WeeklyDetailType = FlowType | 'pending' | 'new';
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
interface WeeklyStats {
|
||||
pendingDelivery: number;
|
||||
weeklyNew: number;
|
||||
weeklyRemoved: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}
|
||||
|
||||
export 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'`;
|
||||
|
||||
// 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
|
||||
export const WEEK_START_SQL = `DATE_SUB(CURDATE(), INTERVAL (WEEKDAY(CURDATE()) + 2) % 7 DAY)`;
|
||||
export const WEEK_END_SQL = `DATE_ADD(${WEEK_START_SQL}, INTERVAL 7 DAY)`;
|
||||
|
||||
export const WEEKLY_TRUCK_ID_SQLS = [
|
||||
`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'`,
|
||||
`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)`,
|
||||
`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)`,
|
||||
`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`,
|
||||
] as const;
|
||||
|
||||
// 交车单 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`;
|
||||
|
||||
export const WEEKLY_DETAIL_SQL: Record<WeeklyDetailType, string> = {
|
||||
delivered: `${DELIVERED_SQL} AND dv.delivery_time >= ${WEEK_START_SQL} AND dv.delivery_time < ${WEEK_END_SQL} ORDER BY dv.delivery_time DESC`,
|
||||
returned: `${RETURNED_SQL} AND r.arrival_time >= ${WEEK_START_SQL} AND r.arrival_time < ${WEEK_END_SQL} ORDER BY r.arrival_time DESC`,
|
||||
replaced: `${REPLACED_SQL} AND vr.replace_time >= ${WEEK_START_SQL} AND vr.replace_time < ${WEEK_END_SQL} ORDER BY vr.replace_time DESC`,
|
||||
pending: `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'`,
|
||||
new: `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`,
|
||||
};
|
||||
|
||||
export const FLOW_STATS_SQL = `
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
CONCAT('delivered-', dv.id) AS id,
|
||||
'delivered' AS type,
|
||||
'交车' AS type_label,
|
||||
DATE_FORMAT(dv.create_time, '%Y-%m-%d') AS stat_date,
|
||||
CAST(dv.vehicle_id AS CHAR) AS truck_id,
|
||||
dv.plate_number,
|
||||
DATE_FORMAT(dv.delivery_time, '%Y-%m-%d %H:%i:%s') AS event_time,
|
||||
DATE_FORMAT(dv.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
||||
c.business_department_name AS department,
|
||||
c.business_manager_name AS manager,
|
||||
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
||||
FROM delivery_vehicle dv
|
||||
LEFT JOIN delivery_task_subject dts
|
||||
ON dts.id = dv.delivery_task_subject_id
|
||||
AND dts.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_contract_info c
|
||||
ON c.order_id = dv.contract_id
|
||||
AND c.del_flag = '0'
|
||||
WHERE dv.del_flag = '0'
|
||||
AND dv.vehicle_id IS NOT NULL
|
||||
AND dv.create_time IS NOT NULL
|
||||
AND dv.delivery_status IN (2,3,5)
|
||||
AND dv.create_time >= ?
|
||||
AND dv.create_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONCAT('returned-', r.id) AS id,
|
||||
'returned' AS type,
|
||||
'还车' AS type_label,
|
||||
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
|
||||
CAST(r.vehicle_id AS CHAR) AS truck_id,
|
||||
r.plate_number,
|
||||
DATE_FORMAT(r.arrival_time, '%Y-%m-%d %H:%i:%s') AS event_time,
|
||||
DATE_FORMAT(r.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
||||
c.business_department_name AS department,
|
||||
c.business_manager_name AS manager,
|
||||
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
||||
FROM return_vehicle_task r
|
||||
LEFT JOIN delivery_task_subject dts
|
||||
ON dts.id = r.delivery_task_subject_id
|
||||
AND dts.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_contract_info c
|
||||
ON c.order_id = r.contract_id
|
||||
AND c.del_flag = '0'
|
||||
WHERE r.del_flag = '0'
|
||||
AND r.vehicle_id IS NOT NULL
|
||||
AND r.create_time IS NOT NULL
|
||||
AND r.status IN (2,3,5)
|
||||
AND r.create_time >= ?
|
||||
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONCAT('replaced-', vr.id) AS id,
|
||||
'replaced' AS type,
|
||||
'替换' AS type_label,
|
||||
DATE_FORMAT(vr.create_time, '%Y-%m-%d') AS stat_date,
|
||||
CAST(vr.new_vehicle_id AS CHAR) AS truck_id,
|
||||
vr.new_vehicle_plate AS plate_number,
|
||||
DATE_FORMAT(vr.replace_time, '%Y-%m-%d %H:%i:%s') AS event_time,
|
||||
DATE_FORMAT(vr.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time,
|
||||
c.business_department_name AS department,
|
||||
c.business_manager_name AS manager,
|
||||
COALESCE(dts.customer_name, c.customer_name) AS customer_name
|
||||
FROM vehicle_replacement vr
|
||||
LEFT JOIN delivery_task_subject dts
|
||||
ON dts.id = vr.delivery_task_subject_id
|
||||
AND dts.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_contract_info c
|
||||
ON c.id = vr.contract_id
|
||||
AND c.del_flag = '0'
|
||||
WHERE vr.del_flag = '0'
|
||||
AND vr.new_vehicle_id IS NOT NULL
|
||||
AND vr.create_time IS NOT NULL
|
||||
AND vr.status = 20
|
||||
AND vr.create_time >= ?
|
||||
AND vr.create_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
) flow
|
||||
ORDER BY flow.submit_time DESC
|
||||
`;
|
||||
|
||||
const CACHE_TTL = 60 * 1000;
|
||||
|
||||
export class VehicleRepository {
|
||||
private cachedVehicles: Vehicle[] = [];
|
||||
private lastFetchTime = 0;
|
||||
private cachedWeeklyTruckIds: WeeklyTruckIds | null = null;
|
||||
private weeklyTruckIdsLastFetch = 0;
|
||||
private cachedWeeklyStats: WeeklyStats | null = null;
|
||||
private weeklyStatsLastFetch = 0;
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly mapVehicleRow: (row: VehicleRow) => Vehicle = transformRow,
|
||||
) {}
|
||||
|
||||
async getVehicles(): Promise<Vehicle[]> {
|
||||
const now = this.now();
|
||||
if (this.cachedVehicles.length > 0 && now - this.lastFetchTime < CACHE_TTL) {
|
||||
return this.cachedVehicles;
|
||||
}
|
||||
const [rows] = await this.db.query<any[]>(MAIN_SQL);
|
||||
this.cachedVehicles = (rows as VehicleRow[]).map(this.mapVehicleRow);
|
||||
this.lastFetchTime = now;
|
||||
return this.cachedVehicles;
|
||||
}
|
||||
|
||||
async getWeeklyTruckIds(): Promise<WeeklyTruckIds> {
|
||||
const now = this.now();
|
||||
if (this.cachedWeeklyTruckIds && now - this.weeklyTruckIdsLastFetch < CACHE_TTL) {
|
||||
return this.cachedWeeklyTruckIds;
|
||||
}
|
||||
|
||||
const [[pendingRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all(
|
||||
WEEKLY_TRUCK_ID_SQLS.map((sql) => this.db.query<any[]>(sql)),
|
||||
);
|
||||
const toSet = (rows: any[]) => new Set(rows.map((r) => String(r.truck_id)).filter((s) => s && s !== 'null'));
|
||||
this.cachedWeeklyTruckIds = {
|
||||
pending: toSet(pendingRows as any[]),
|
||||
delivered: toSet(deliveredRows as any[]),
|
||||
returned: toSet(returnedRows as any[]),
|
||||
replaced: toSet(replacedRows as any[]),
|
||||
};
|
||||
this.weeklyTruckIdsLastFetch = now;
|
||||
return this.cachedWeeklyTruckIds;
|
||||
}
|
||||
|
||||
async getWeeklyStats(): Promise<WeeklyStats> {
|
||||
const now = this.now();
|
||||
if (this.cachedWeeklyStats && now - this.weeklyStatsLastFetch < CACHE_TTL) {
|
||||
return this.cachedWeeklyStats;
|
||||
}
|
||||
|
||||
const [[pendingRows], [newRows], [removedRows], [deliveredRows], [returnedRows], [replacedRows]] = await Promise.all([
|
||||
this.db.query<any[]>(`SELECT COUNT(*) AS cnt FROM vehicle_status WHERE del_flag=0 AND vehicle_status='4' AND COALESCE(operation_status, '') <> '5'`),
|
||||
this.db.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}`),
|
||||
this.db.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}`),
|
||||
this.db.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}`),
|
||||
this.db.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}`),
|
||||
this.db.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}`),
|
||||
]);
|
||||
|
||||
this.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,
|
||||
};
|
||||
this.weeklyStatsLastFetch = now;
|
||||
return this.cachedWeeklyStats;
|
||||
}
|
||||
|
||||
invalidateRefreshCaches(): void {
|
||||
this.lastFetchTime = 0;
|
||||
this.weeklyStatsLastFetch = 0;
|
||||
}
|
||||
|
||||
async getRealtimeMileageRows(): Promise<any[]> {
|
||||
const [rows] = await this.db.query<any[]>(`
|
||||
SELECT plate_number, day_mileage
|
||||
FROM tab_truck_remote_sync_realtime_info
|
||||
WHERE is_deleted = 0 AND plate_number IS NOT NULL
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getAttendanceMileageRows(): Promise<any[]> {
|
||||
const [rows] = await this.db.query<any[]>(`SELECT plate_number, day_mileage FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getWeeklyDetailRows(type: WeeklyDetailType): Promise<any[]> {
|
||||
const [rows] = await this.db.query<any[]>(WEEKLY_DETAIL_SQL[type]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getFlowDetailRows(start: string, end: string): Promise<FlowDetailRow[]> {
|
||||
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end, start, end]);
|
||||
return rows as FlowDetailRow[];
|
||||
}
|
||||
|
||||
async getWeeklyDebugData(): Promise<{
|
||||
dateRange: any;
|
||||
deliveredAll: any;
|
||||
deliveredRecent: any;
|
||||
latestTake: any;
|
||||
returnedRecent: any;
|
||||
latestReturn: any;
|
||||
}> {
|
||||
const [[dateRange]] = await this.db.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 this.db.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 this.db.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 this.db.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 this.db.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 this.db.query<any[]>(`SELECT MAX(arrival_time) AS latest FROM return_vehicle_task
|
||||
WHERE del_flag='0' AND arrival_time IS NOT NULL AND status IN (2,3,5)`);
|
||||
|
||||
return { dateRange, deliveredAll, deliveredRecent, latestTake, returnedRecent, latestReturn };
|
||||
}
|
||||
|
||||
async getRealtimeProvinceRows(): Promise<any[]> {
|
||||
const [rows] = await this.db.query<any[]>(`SELECT plate_number, province FROM tab_truck_remote_sync_realtime_info WHERE is_deleted = 0 AND plate_number IS NOT NULL`);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
export const vehicleRepository = new VehicleRepository(pool as unknown as Database);
|
||||
@@ -0,0 +1,26 @@
|
||||
export type VehicleTypeCategory = 't4_5' | 't4_5c' | 't18' | 't49' | 'trailer' | 'other';
|
||||
|
||||
export type VehicleTypeCounts = Record<VehicleTypeCategory, number> & { total: number };
|
||||
|
||||
export interface WeeklyTruckIds {
|
||||
pending: Set<string>;
|
||||
delivered: Set<string>;
|
||||
returned: Set<string>;
|
||||
replaced: Set<string>;
|
||||
}
|
||||
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
inventory: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
weeklyDelivered: number;
|
||||
weeklyReturned: number;
|
||||
weeklyReplaced: number;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Vehicle } from '../../types.js';
|
||||
import { REGIONS, classifyVehicleType } from './model.js';
|
||||
import type { DateRange, VehicleStats, VehicleTypeCounts, WeeklyTruckIds } from './types.js';
|
||||
|
||||
export 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 vehicle of vehicles) {
|
||||
counts[classifyVehicleType(vehicle)] += 1;
|
||||
counts.total += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record<string, number> {
|
||||
return regions.reduce<Record<string, number>>((counts, region) => {
|
||||
counts[region] = vehicles.filter((vehicle) => vehicle.location === region).length;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
|
||||
const ids = list.map((vehicle) => String(vehicle.id));
|
||||
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory' || vehicle.status === 'Abnormal');
|
||||
return {
|
||||
total: list.length,
|
||||
inventory: inventory.length,
|
||||
inventoryRegions: getRegionCounts(inventory, REGIONS),
|
||||
pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
|
||||
operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
|
||||
weeklyDelivered: weeklyIds ? ids.filter((id) => weeklyIds.delivered.has(id)).length : 0,
|
||||
weeklyReturned: weeklyIds ? ids.filter((id) => weeklyIds.returned.has(id)).length : 0,
|
||||
weeklyReplaced: weeklyIds ? ids.filter((id) => weeklyIds.replaced.has(id)).length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMileageStats(vehicles: Vehicle[], mileageByPlate: ReadonlyMap<string, number>): {
|
||||
attendanceRate: number;
|
||||
avgMileage: number;
|
||||
} {
|
||||
const active = vehicles.filter((vehicle) => (mileageByPlate.get(vehicle.plateNumber) || 0) > 0).length;
|
||||
let totalMileage = 0;
|
||||
let count = 0;
|
||||
for (const vehicle of vehicles) {
|
||||
const mileage = mileageByPlate.get(vehicle.plateNumber);
|
||||
if (mileage !== undefined && mileage > 0) {
|
||||
totalMileage += mileage;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
attendanceRate: vehicles.length > 0 ? Math.round((active / vehicles.length) * 1000) / 10 : 0,
|
||||
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const DEPARTMENT_NUMBERS: Record<string, number> = {
|
||||
'一': 1,
|
||||
'二': 2,
|
||||
'三': 3,
|
||||
'四': 4,
|
||||
'五': 5,
|
||||
'六': 6,
|
||||
'七': 7,
|
||||
'八': 8,
|
||||
'九': 9,
|
||||
'十': 10,
|
||||
};
|
||||
|
||||
export function compareDepartmentNames(left: string, right: string): number {
|
||||
const getOrder = (name: string): number => {
|
||||
if (name === '公务车') return 100;
|
||||
const match = name.match(/[一二三四五六七八九十]/);
|
||||
return match ? (DEPARTMENT_NUMBERS[match[0]] || 99) : 99;
|
||||
};
|
||||
return getOrder(left) - getOrder(right);
|
||||
}
|
||||
|
||||
export function formatDateOnly(value: Date): string {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function isDateParam(value: string | undefined | null): value is string {
|
||||
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
|
||||
}
|
||||
|
||||
export function addDateDays(date: string, days: number): string {
|
||||
const value = new Date(`${date}T00:00:00`);
|
||||
value.setDate(value.getDate() + days);
|
||||
return formatDateOnly(value);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function normalizeDateRange(
|
||||
startRaw: string | undefined,
|
||||
endRaw: string | undefined,
|
||||
now: Date = new Date(),
|
||||
): DateRange {
|
||||
const today = formatDateOnly(now);
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user