refactor: modularize application domains

This commit is contained in:
kkfluous
2026-08-13 12:03:34 +08:00
parent d610e4b841
commit 06fe75b4c7
142 changed files with 14645 additions and 8885 deletions
+249
View File
@@ -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), ['业务一部', '业务二部', '业务十部', '未分配部门', '公务车']);
});
+254
View File
@@ -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'],
},
]);
});
+462
View File
@@ -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);
+26
View File
@@ -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;
}
+125
View File
@@ -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 };
}