Merge feature/asset-statistics into main and release v1.2.1
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { transformRow } from './model.js';
|
||||
import type { VehicleRow } from '../../types.js';
|
||||
|
||||
const row = { id: 'vehicle-1', 车辆租赁状态: '14', 客户经理: '合同经理', 合同归属部门: '合同部门' } as VehicleRow;
|
||||
|
||||
test('movement details use source timestamps and preserve work without borrowing lease ownership', () => {
|
||||
const vehicle = transformRow({ ...row, abnormal_move_id: '1234567890123456789', abnormal_move_type: 1,
|
||||
abnormal_move_status: 3, abnormal_move_work: '更换制动部件;道路验证',
|
||||
abnormal_move_target_time: '2026-09-23 18:00', abnormal_move_actual_time: null });
|
||||
assert.equal(vehicle.abnormalMove?.id, '1234567890123456789');
|
||||
assert.equal(vehicle.abnormalMove?.type, '维修');
|
||||
assert.equal(vehicle.abnormalMove?.work, '更换制动部件;道路验证');
|
||||
assert.equal(vehicle.abnormalMove?.targetCompletionTime, '2026-09-23 18:00');
|
||||
assert.equal(vehicle.abnormalMove?.actualCompletionTime, null);
|
||||
for (const key of ['responsiblePerson', 'initiatingDepartment', 'executingDepartment', 'driver'] as const) {
|
||||
assert.equal(vehicle.abnormalMove?.[key], null);
|
||||
}
|
||||
});
|
||||
|
||||
test('completed movement retains its actual time and missing records stay distinct', () => {
|
||||
const completed = transformRow({ ...row, abnormal_move_id: '2', abnormal_move_type: 4,
|
||||
abnormal_move_status: 5, abnormal_move_work: ' ', abnormal_move_actual_time: '2026-09-22 12:30' });
|
||||
assert.equal(completed.abnormalMove?.status, '已完成');
|
||||
assert.equal(completed.abnormalMove?.actualCompletionTime, '2026-09-22 12:30');
|
||||
assert.equal(completed.abnormalMove?.work, null);
|
||||
assert.equal(transformRow(row).abnormalMove, null);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import app from './routes.js';
|
||||
import { vehicleRepository } from './repository.js';
|
||||
import type { Vehicle } from '../../types.js';
|
||||
|
||||
// Repository stubs keep this regression entirely offline.
|
||||
test('asset drill-down preserves company, region scope, vehicle type and actual cities', async (t) => {
|
||||
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
|
||||
id, plateNumber: `TEST-${id}`, vin: id, type: '4.5T', model: '测试普货', color: '白',
|
||||
location: '嘉兴', region: '嘉兴', province: '浙江省', city: `测试城市${id}`,
|
||||
status: 'Operating', operationStatus: '1', ownership: 'Rental', rentCompany: '',
|
||||
contractNo: null, customerName: null, orgName: null, departmentName: null,
|
||||
subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null, brandLabel: null,
|
||||
...overrides,
|
||||
});
|
||||
const vehicles = Array.from({ length: 9 }, (_, i) => make(String(i + 1)));
|
||||
vehicles.push(make('10', { operationStatus: '3', status: 'Inventory' }));
|
||||
vehicles.push(make('11', { type: '挂车', model: '测试挂车' }));
|
||||
vehicles.push(make('12', { subjectOrg: '公司乙' }));
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
t.mock.method(vehicleRepository, 'getWeeklyDetailRows', async () => [
|
||||
{ truck_id: '1', plate_number: 'TEST-1', handover_date: null, contract_type: null, customer_name: null },
|
||||
{ truck_id: '12', plate_number: 'TEST-12', handover_date: null, contract_type: null, customer_name: null },
|
||||
]);
|
||||
const read = async (path: string) => {
|
||||
const response = await app.request(path);
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
};
|
||||
const regions = await read('/region-stats?subject=公司甲');
|
||||
assert.equal(regions[0].totalAssets, 10);
|
||||
assert.equal(regions[0].cities.length, 10, 'cities beyond the first eight stay individually addressable');
|
||||
const total = await read('/list?source=region&location=华东&subject=公司甲');
|
||||
assert.equal(total.length, regions[0].totalAssets);
|
||||
assert.ok(total.every((v: Vehicle) => v.id !== '10' && v.id !== '12'));
|
||||
const light = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=4.5T');
|
||||
assert.equal(light.length, 9);
|
||||
const other = await read('/list?source=region&location=华东&subject=公司甲&vehicleType=区域其他');
|
||||
assert.deepEqual(other.map((v: Vehicle) => v.id), ['11']);
|
||||
const weekly = await read('/weekly-detail?type=delivered&subject=公司甲');
|
||||
assert.deepEqual(weekly.map((v: {truck_id: string}) => v.truck_id), ['1']);
|
||||
});
|
||||
|
||||
test('inventory brand drill-down matches each same-model row, including unknown brand', async (t) => {
|
||||
const make = (id: string, brandLabel: string | null, subjectOrg = '公司甲'): Vehicle => ({
|
||||
id, plateNumber: `TEST-${id}`, vin: id, type: '挂车', model: '挂车', color: '白',
|
||||
location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
|
||||
status: 'Inventory', operationStatus: '3', ownership: 'Rental', rentCompany: '',
|
||||
contractNo: null, customerName: null, orgName: null, departmentName: null,
|
||||
subjectOrg, projectName: null, customerManager: null, managerId: null, brandLabel,
|
||||
});
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => [make('1', '明威'), make('2', '明威'), make('3', '万风'), make('4', null), make('5', '明威', '公司乙')]);
|
||||
const summary = await (await app.request('/inventory-stats?subject=公司甲')).json();
|
||||
for (const row of summary) {
|
||||
const query = new URLSearchParams({ source: 'asset', subject: '公司甲', category: 'Inventory', model: row.model, location: row.city, vehicleType: row.type, brand: row.brand });
|
||||
const result = await app.request(`/list?${query}`);
|
||||
assert.equal(result.status, 200);
|
||||
const detail = await result.json();
|
||||
assert.equal(detail.length, row.quantity);
|
||||
assert.ok(detail.every((vehicle: { brandLabel: string | null }) => (vehicle.brandLabel || '未知') === row.brand));
|
||||
}
|
||||
});
|
||||
|
||||
test('inventory composition and color variants reconcile with permission-filtered list keys', async (t) => {
|
||||
const make = (id: string, operationStatus: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
|
||||
id,plateNumber:`TEST-${id}`,vin:id,type:'18T',model:'现代18T双飞翼(羚牛)',color:'白',location:'嘉兴',region:'嘉兴',province:'浙江省',city:'嘉兴市',status:'Inventory',operationStatus,ownership:'Self',rentCompany:'',contractNo:null,customerName:null,orgName:null,departmentName:null,subjectOrg:'公司甲',projectName:null,customerManager:null,managerId:null,brandLabel:'帕力安牌',...overrides,
|
||||
});
|
||||
t.mock.method(vehicleRepository,'getVehicles',async () => [make('1','3'),make('2','可运营',{status:'Abnormal',abnormalMove:{type:'维修'} as Vehicle['abnormalMove']}),make('3','4',{status:'Pending',color:'红'}),make('4','待运营'),make('5','1'),make('6','2'),make('7','3',{subjectOrg:'公司乙'})]);
|
||||
const stats = await (await app.request('/inventory-stats?subject=公司甲')).json();
|
||||
assert.equal(stats.length,1);
|
||||
const row = stats[0];
|
||||
assert.equal(row.quantity,4); assert.equal(row.available,2); assert.equal(row.awaiting,2); assert.equal(row.unclassified,0);
|
||||
assert.equal(row.abnormal,1); assert.equal(row.pending,1); assert.deepEqual(row.abnormalTypes,{维修:1});
|
||||
assert.equal(row.variants['白色'].quantity,3); assert.equal(row.variants['红色'].quantity,1);
|
||||
const list = await (await app.request('/list?subject=公司甲&category=Inventory')).json();
|
||||
assert.equal(list.length,4);
|
||||
assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model])));
|
||||
assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1);
|
||||
});
|
||||
@@ -33,7 +33,7 @@ test('replacement trips are included in delivery counts without inflating totals
|
||||
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
|
||||
});
|
||||
|
||||
test('inventory excludes abnormal vehicles in totals, type rollups, regions and drilldowns', async (t) => {
|
||||
test('inventory includes movement and pending subsets consistently in totals, regions and drilldowns', async (t) => {
|
||||
const vehicles = ['Inventory', 'Abnormal', 'Pending', 'Operating'].map((status, i) => ({
|
||||
id: String(i), status, operationStatus: status === 'Operating' ? '1' : '3',
|
||||
plateNumber: `测试${i}`, model: '现代4.5T普货', type: '4.5T', location: '广东',
|
||||
@@ -50,18 +50,66 @@ test('inventory excludes abnormal vehicles in totals, type rollups, regions and
|
||||
};
|
||||
const summary = await get('/summary');
|
||||
assert.equal(summary.totalAssets, 4);
|
||||
assert.equal(summary.inventory.total, 1);
|
||||
assert.equal(summary.inventory.total, 3);
|
||||
assert.equal(summary.inventory.abnormal, 1);
|
||||
assert.equal(summary.totalAssets, summary.inventory.total + summary.inventory.abnormal + summary.operating.total + summary.pendingDelivery);
|
||||
assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0']);
|
||||
assert.equal(summary.totalAssets, summary.inventory.total + summary.operating.total);
|
||||
assert.deepEqual((await get('/list?category=Inventory')).map((v: Vehicle) => v.id), ['0', '1', '2']);
|
||||
assert.deepEqual((await get('/list?category=Abnormal')).map((v: Vehicle) => v.id), ['1']);
|
||||
assert.deepEqual(await get('/list?category=Abnormal&subject=甲公司'), []);
|
||||
const groups = await get('/by-type');
|
||||
assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 1);
|
||||
assert.equal(groups.reduce((n: number, g: { totalInventory: number }) => n + g.totalInventory, 0), 3);
|
||||
assert.equal(groups.reduce((n: number, g: { totalAbnormal: number }) => n + g.totalAbnormal, 0), 1);
|
||||
const model = groups.flatMap((g: { models: unknown[] }) => g.models).find((m: { abnormal: number }) => m.abnormal === 1);
|
||||
assert.equal(model.inventory, 1);
|
||||
assert.equal(model.inventoryRegions['广东'], 1);
|
||||
assert.equal(model.inventory, 3);
|
||||
assert.equal(model.inventoryRegions['广东'], 3);
|
||||
const inventory = await get('/inventory-stats');
|
||||
assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 1);
|
||||
assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 3);
|
||||
assert.equal(inventory.reduce((n: number, row: { abnormal: number }) => n + row.abnormal, 0), 1);
|
||||
assert.equal(inventory.reduce((n: number, row: { pending: number }) => n + row.pending, 0), 1);
|
||||
const subjectInventory = await get('/inventory-stats?subject=甲公司');
|
||||
assert.equal(subjectInventory.reduce((n: number, row: { abnormal: number }) => n + row.abnormal, 0), 0);
|
||||
assert.equal(subjectInventory.reduce((n: number, row: { pending: number }) => n + row.pending, 0), 1);
|
||||
});
|
||||
|
||||
test('operating category includes every rental/self-operated vehicle regardless of workflow status', async (t) => {
|
||||
const vehicles = [
|
||||
{ id: 'rental-movement', operationStatus: '1', status: 'Abnormal' },
|
||||
{ id: 'self-pending', operationStatus: '2', status: 'Pending' },
|
||||
{ id: 'available', operationStatus: '3', status: 'Inventory' },
|
||||
{ id: 'awaiting-operation', operationStatus: '4', status: 'Pending' },
|
||||
] as Vehicle[];
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
|
||||
t.mock.method(vehicleRepository, 'getWeeklyTruckIds', async () => ({
|
||||
pending: new Set<string>(), delivered: new Set<string>(), returned: new Set<string>(), replaced: new Set<string>(),
|
||||
}));
|
||||
const summary = await (await router.request('/summary')).json();
|
||||
assert.equal(summary.operating.total, 2);
|
||||
assert.equal(summary.operating.self, 1);
|
||||
assert.equal(summary.operating.leased, 1);
|
||||
assert.equal(summary.inventory.total, 2);
|
||||
assert.equal(summary.inventory.abnormal, 0);
|
||||
assert.equal(summary.pendingDelivery, 1);
|
||||
const list = async (category: string) => (await (await router.request(`/list?category=${category}`)).json()).map((v: Vehicle) => v.id);
|
||||
assert.deepEqual(await list('Operating'), ['rental-movement', 'self-pending']);
|
||||
assert.deepEqual(await list('Inventory'), ['available', 'awaiting-operation']);
|
||||
assert.deepEqual(await list('Pending'), ['awaiting-operation']);
|
||||
assert.deepEqual(await list('Abnormal'), []);
|
||||
});
|
||||
|
||||
test('vehicle drilldowns return movement data and mask the current pending customer without mutating cached source', async (t) => {
|
||||
const pending = { id: 'p', status: 'Pending', operationStatus: '3',
|
||||
pendingDelivery: { contractNo: 'CONTRACT-2026-01', customerName: '测试客户有限公司' } } as Vehicle;
|
||||
const movement = { id: 'm', status: 'Abnormal', operationStatus: '4',
|
||||
abnormalMove: { id: 'move-1', type: '保养', targetCompletionTime: '2026-09-26 18:00' } } as Vehicle;
|
||||
t.mock.method(vehicleRepository, 'getVehicles', async () => [pending, movement]);
|
||||
const response = await router.request('/list?category=Pending');
|
||||
const body = await response.text();
|
||||
const data = JSON.parse(body);
|
||||
assert.equal(data[0].pendingDelivery.contractNo, 'CONTRACT-2026-01');
|
||||
assert.match(data[0].pendingDelivery.customerName, /\*\*\*/);
|
||||
assert.ok(!body.includes('测试客户有限公司'));
|
||||
assert.equal(pending.pendingDelivery?.customerName, '测试客户有限公司');
|
||||
const moves = await (await router.request('/list?category=Abnormal')).json();
|
||||
assert.equal(moves[0].abnormalMove.type, '保养');
|
||||
assert.equal(moves[0].abnormalMove.targetCompletionTime, '2026-09-26 18:00');
|
||||
});
|
||||
|
||||
@@ -163,6 +163,8 @@ test('transforms database rows without changing the route model contract', () =>
|
||||
|
||||
assert.deepEqual(transformRow(row), {
|
||||
id: '88',
|
||||
abnormalMove: null,
|
||||
pendingDelivery: null,
|
||||
plateNumber: '粤A00088',
|
||||
vin: 'VIN-88',
|
||||
type: '4.5T',
|
||||
@@ -191,9 +193,9 @@ test('transforms database rows without changing the route model contract', () =>
|
||||
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: '叉车' }),
|
||||
vehicle({ id: '2', plateNumber: 'B', status: 'Inventory', operationStatus: '3', location: '嘉兴', type: '4.5T', model: '冷链' }),
|
||||
vehicle({ id: '3', plateNumber: 'C', status: 'Abnormal', operationStatus: '4', location: '北京', type: '挂车', model: '挂车' }),
|
||||
vehicle({ id: '4', plateNumber: 'D', status: 'Pending', operationStatus: '3', type: '叉车', model: '叉车' }),
|
||||
];
|
||||
const weeklyIds = {
|
||||
pending: new Set<string>(),
|
||||
@@ -213,9 +215,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () =
|
||||
});
|
||||
assert.deepEqual(getStats(vehicles, weeklyIds), {
|
||||
total: 4,
|
||||
inventory: 1,
|
||||
inventory: 3,
|
||||
abnormal: 1,
|
||||
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 },
|
||||
inventoryRegions: { 嘉兴: 1, 广东: 1, 北京: 1, 新疆: 0, 其他: 0 },
|
||||
pending: 1,
|
||||
operating: 1,
|
||||
weeklyDelivered: 1,
|
||||
|
||||
@@ -64,6 +64,23 @@ export function mapStatus(operationStatus: string | null, vehicleStatus: string
|
||||
return 'Inventory';
|
||||
}
|
||||
|
||||
/** Operational category is independent from movement/delivery workflow status. */
|
||||
export function isOperatingVehicle(vehicle: Vehicle): boolean {
|
||||
return ['1', '2', '租赁', '自营'].includes((vehicle.operationStatus || '').trim());
|
||||
}
|
||||
|
||||
export function isInventoryVehicle(vehicle: Vehicle): boolean {
|
||||
return ['3', '4', '可运营', '待运营', '在库'].includes((vehicle.operationStatus || '').trim());
|
||||
}
|
||||
|
||||
export function isInventoryAbnormal(vehicle: Vehicle): boolean {
|
||||
return isInventoryVehicle(vehicle) && vehicle.status === 'Abnormal';
|
||||
}
|
||||
|
||||
export function isInventoryPending(vehicle: Vehicle): boolean {
|
||||
return isInventoryVehicle(vehicle) && vehicle.status === 'Pending';
|
||||
}
|
||||
|
||||
export function mapOwnership(rentStatusLabel: string | null): string {
|
||||
if (!rentStatusLabel) return 'Unknown';
|
||||
const status = rentStatusLabel.trim();
|
||||
@@ -195,6 +212,24 @@ export function transformRow(row: VehicleRow): Vehicle {
|
||||
const region = mapRegion(row.省, row.市);
|
||||
return {
|
||||
id: row.id,
|
||||
pendingDelivery: row.pending_delivery_id ? {
|
||||
contractNo: row.pending_contract_no || null,
|
||||
customerName: row.pending_customer_name || null,
|
||||
} : null,
|
||||
abnormalMove: row.abnormal_move_id ? {
|
||||
id: row.abnormal_move_id,
|
||||
type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record<number, string>)[Number(row.abnormal_move_type)] || '未提供',
|
||||
status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束',
|
||||
work: row.abnormal_move_work?.trim() || null,
|
||||
targetCompletionTime: row.abnormal_move_target_time || null,
|
||||
actualCompletionTime: row.abnormal_move_actual_time || null,
|
||||
// The source has no confirmed department, assignee or operating-driver fields.
|
||||
// Do not substitute lease ownership, creator/updater or the vehicle's bound driver.
|
||||
initiatingDepartment: null,
|
||||
executingDepartment: null,
|
||||
responsiblePerson: null,
|
||||
driver: null,
|
||||
} : null,
|
||||
plateNumber: row.车牌号 || '',
|
||||
vin: row.vin || '',
|
||||
type: deriveType(row.车辆型号Label, row.车辆类型参数),
|
||||
|
||||
@@ -49,7 +49,7 @@ test('keeps all vehicle GET endpoints in their established order', () => {
|
||||
assert.doesNotMatch(source, /\bSELECT\b|pool\.query|MAIN_SQL/);
|
||||
});
|
||||
|
||||
test('keeps the non-empty vehicle cache for less than 60 seconds', async () => {
|
||||
test('keeps the non-empty vehicle cache for less than 5 seconds', async () => {
|
||||
let now = 100_000;
|
||||
const db = new FakeDatabase([[{ id: 'first' }], [{ id: 'second' }]]);
|
||||
const repository = new VehicleRepository(
|
||||
@@ -59,7 +59,7 @@ test('keeps the non-empty vehicle cache for less than 60 seconds', async () => {
|
||||
);
|
||||
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
||||
now += 59_999;
|
||||
now += 4_999;
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('first')]);
|
||||
now += 1;
|
||||
assert.deepEqual(await repository.getVehicles(), [vehicle('second')]);
|
||||
@@ -145,9 +145,9 @@ test('replacement trips come exclusively from linked delivery records', () => {
|
||||
assert.doesNotMatch(FLOW_STATS_SQL, /FROM vehicle_replacement|dv\.delivery_time|r\.arrival_time|vr\.replace_time/);
|
||||
});
|
||||
|
||||
test('completed delivery and replacement trips use vehicle status and exclude suspended subjects', () => {
|
||||
test('delivery and replacement trips include awaiting customer signature and exclude suspended subjects', () => {
|
||||
const [deliveries, returns] = FLOW_STATS_SQL.split('UNION ALL');
|
||||
assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,20)'));
|
||||
assert.ok(deliveries.includes('AND dv.delivery_status IN (2,3,5,20)'));
|
||||
assert.ok(deliveries.includes('AND (dts.status IS NULL OR dts.status <> 10)'));
|
||||
assert.doesNotMatch(deliveries, /dts\.status\s*(?:=|IN)|dv\.delivery_status IN \(2,3,5\)/);
|
||||
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pool from '../../db/mysql.js';
|
||||
import { LATEST_VEHICLE_LEASE_JOIN_SQL } from '../../vehicle-lease-sql.js';
|
||||
import type { Vehicle, VehicleRow } from '../../types.js';
|
||||
import { transformRow } from './model.js';
|
||||
import type { WeeklyTruckIds } from './types.js';
|
||||
@@ -36,6 +37,15 @@ interface WeeklyStats {
|
||||
|
||||
export const MAIN_SQL = `SELECT
|
||||
CAST(vi.id AS CHAR) AS id,
|
||||
CAST(am.id AS CHAR) AS abnormal_move_id,
|
||||
am.move_type AS abnormal_move_type,
|
||||
CAST(pdv.id AS CHAR) AS pending_delivery_id,
|
||||
pdts.contract_code AS pending_contract_no,
|
||||
pdts.customer_name AS pending_customer_name,
|
||||
am.move_status AS abnormal_move_status,
|
||||
CONCAT_WS(';', NULLIF(TRIM(am.remark), ''), NULLIF(TRIM(amr.remark), '')) AS abnormal_move_work,
|
||||
DATE_FORMAT(am.move_plan_end_time, '%Y-%m-%d %H:%i') AS abnormal_move_target_time,
|
||||
DATE_FORMAT(am.move_end_time, '%Y-%m-%d %H:%i') AS abnormal_move_actual_time,
|
||||
vi.plate_number AS 车牌号,
|
||||
vi.vin AS vin,
|
||||
vm.brand AS 车辆品牌,
|
||||
@@ -80,13 +90,13 @@ export const MAIN_SQL = `SELECT
|
||||
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 客户名称,
|
||||
vor.contract_code AS 合同编码,
|
||||
vor.customer_name AS 客户名称,
|
||||
c.signing_company AS 合同归属公司,
|
||||
COALESCE(c.business_department_name, vor.business_dept) AS 合同归属部门,
|
||||
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 客户经理,
|
||||
vor.project_name AS 项目名称,
|
||||
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
|
||||
@@ -113,21 +123,31 @@ LEFT JOIN common_district vi_city
|
||||
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'
|
||||
-- Pending delivery uses the current active task, never a completed historical lease.
|
||||
LEFT JOIN delivery_vehicle pdv ON pdv.id = (
|
||||
SELECT dv.id
|
||||
FROM delivery_vehicle dv
|
||||
JOIN delivery_task_subject dts ON dts.id = dv.delivery_task_subject_id
|
||||
AND dts.del_flag = '0' AND dts.status = 0
|
||||
WHERE dv.vehicle_id = vi.id AND dv.del_flag = '0' AND dv.status = 0
|
||||
AND dv.delivery_status IN (0, 1) AND vs.vehicle_status = '4'
|
||||
ORDER BY dv.create_time DESC, dv.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN delivery_task_subject pdts ON pdts.id = pdv.delivery_task_subject_id
|
||||
-- Select one effective movement per vehicle; drafts and withdrawals are not operational work.
|
||||
-- An ongoing movement takes precedence over completed history.
|
||||
LEFT JOIN vehicle_abnormal_move_record amr ON amr.id = (
|
||||
SELECT mr.id
|
||||
FROM vehicle_abnormal_move_record mr
|
||||
JOIN vehicle_abnormal_move m ON m.id = mr.move_id AND m.del_flag = '0'
|
||||
WHERE mr.vehicle_id = vi.id AND mr.del_flag = '0' AND m.move_status IN (3, 5)
|
||||
AND vs.vehicle_status = '14'
|
||||
ORDER BY (m.move_status = 3) DESC, COALESCE(m.move_start_time, m.create_time) DESC, m.id DESC, mr.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
LEFT JOIN vehicle_abnormal_move am ON am.id = amr.move_id
|
||||
${LATEST_VEHICLE_LEASE_JOIN_SQL}
|
||||
WHERE vi.del_flag = '0'
|
||||
AND COALESCE(vs.operation_status, '') <> '5'`;
|
||||
|
||||
@@ -252,7 +272,8 @@ export const FLOW_STATS_SQL = `
|
||||
WHERE dv.del_flag = '0'
|
||||
AND dv.vehicle_id IS NOT NULL
|
||||
AND dv.delivery_completion_processed_time IS NOT NULL
|
||||
AND dv.delivery_status IN (2,3,20)
|
||||
-- 待客户签章也计入交车闭环,日期仍按运维签章时间归属。
|
||||
AND dv.delivery_status IN (2,3,5,20)
|
||||
AND (dts.status IS NULL OR dts.status <> 10)
|
||||
AND dv.delivery_completion_processed_time >= ?
|
||||
AND dv.delivery_completion_processed_time < DATE_ADD(?, INTERVAL 1 DAY)
|
||||
@@ -290,7 +311,7 @@ export const FLOW_STATS_SQL = `
|
||||
ORDER BY flow.event_time DESC, flow.id DESC
|
||||
`;
|
||||
|
||||
const CACHE_TTL = 60 * 1000;
|
||||
const CACHE_TTL = 5 * 1000;
|
||||
|
||||
export class VehicleRepository {
|
||||
private cachedVehicles: Vehicle[] = [];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inventoryKey, inventoryOperation, inventoryColor, emptyInventoryCounts, type InventoryCounts } from '../../../shared/assets/inventory.js';
|
||||
import { Hono } from 'hono';
|
||||
import type {
|
||||
Vehicle,
|
||||
@@ -13,6 +14,10 @@ import type { AuthUser } from '../../auth/types.js';
|
||||
import type { Context } from 'hono';
|
||||
import {
|
||||
INVENTORY_REGIONS,
|
||||
isOperatingVehicle,
|
||||
isInventoryVehicle,
|
||||
isInventoryAbnormal,
|
||||
isInventoryPending,
|
||||
SUMMARY_TYPE_FILTERS,
|
||||
VEHICLE_TYPE_FILTERS,
|
||||
classifyVehicleType,
|
||||
@@ -63,18 +68,18 @@ app.get('/summary', async (c) => {
|
||||
const summary: SummaryData = {
|
||||
totalAssets: vehicles.length,
|
||||
operating: {
|
||||
total: vehicles.filter((v) => v.status === 'Operating' && (v.operationStatus === '1' || v.operationStatus === '2')).length,
|
||||
self: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '2').length,
|
||||
leased: vehicles.filter((v) => v.status === 'Operating' && v.operationStatus === '1').length,
|
||||
public: vehicles.filter((v) => v.status === 'Operating' && v.ownership === 'Public').length,
|
||||
total: vehicles.filter((v) => isOperatingVehicle(v)).length,
|
||||
self: vehicles.filter((v) => ['2', '自营'].includes(v.operationStatus || '')).length,
|
||||
leased: vehicles.filter((v) => ['1', '租赁'].includes(v.operationStatus || '')).length,
|
||||
public: vehicles.filter((v) => isOperatingVehicle(v) && v.ownership === 'Public').length,
|
||||
hanging: 0,
|
||||
},
|
||||
inventory: {
|
||||
total: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
|
||||
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
|
||||
total: vehicles.filter((v) => isInventoryVehicle(v)).length,
|
||||
inStock: vehicles.filter((v) => isInventoryVehicle(v) && v.status === 'Inventory').length,
|
||||
abnormal: vehicles.filter((v) => isInventoryAbnormal(v)).length,
|
||||
},
|
||||
pendingDelivery: vehicles.filter((v) => v.status === 'Pending').length,
|
||||
pendingDelivery: vehicles.filter((v) => isInventoryPending(v)).length,
|
||||
weeklyNew: 0,
|
||||
weeklyRemoved: 0,
|
||||
weeklyDelivered: [...weeklyIds.delivered].filter(id => vehicleIds.has(id)).length,
|
||||
@@ -164,7 +169,7 @@ app.get('/inventory-analysis', async (c) => {
|
||||
|
||||
const modelData = models.map((model) => {
|
||||
const modelVehicles = typeVehicles.filter((v) => v.model === model);
|
||||
const inventoryVehicles = modelVehicles.filter((v) => v.status === 'Inventory');
|
||||
const inventoryVehicles = modelVehicles.filter((v) => isInventoryVehicle(v));
|
||||
|
||||
return {
|
||||
model,
|
||||
@@ -180,7 +185,7 @@ app.get('/inventory-analysis', async (c) => {
|
||||
};
|
||||
});
|
||||
|
||||
const typeInventory = typeVehicles.filter((v) => v.status === 'Inventory');
|
||||
const typeInventory = typeVehicles.filter((v) => isInventoryVehicle(v));
|
||||
|
||||
return {
|
||||
type: t.name,
|
||||
@@ -203,7 +208,7 @@ app.get('/inventory-analysis', async (c) => {
|
||||
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
|
||||
app.get('/dept-stats', async (c) => {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
||||
const withManager = vehicles.filter((v) => isOperatingVehicle(v));
|
||||
|
||||
// Query realtime day_mileage from tab_truck_remote_sync_realtime_info
|
||||
const realtimeRows = await vehicleRepository.getRealtimeMileageRows();
|
||||
@@ -252,7 +257,7 @@ app.get('/dept-stats', async (c) => {
|
||||
app.get('/region-stats', async (c) => {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
const { customer, city: filterCity, region: filterRegion } = c.req.query();
|
||||
let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
||||
let operating = vehicles.filter((v) => isOperatingVehicle(v) || isInventoryPending(v));
|
||||
if (customer) operating = operating.filter((v) => v.customerName === customer);
|
||||
if (filterCity) operating = operating.filter((v) => resolveCity(v.city, v.province) === filterCity);
|
||||
if (filterRegion) operating = operating.filter((v) => mapMacroRegion(v.province, v.city) === filterRegion);
|
||||
@@ -272,10 +277,10 @@ app.get('/region-stats', async (c) => {
|
||||
const make = (label: string, tv: Vehicle[]) => ({
|
||||
type: label,
|
||||
total: tv.length,
|
||||
operating: tv.filter((v) => v.status === 'Operating').length,
|
||||
inventory: tv.filter((v) => v.status === 'Inventory').length,
|
||||
abnormal: tv.filter((v) => v.status === 'Abnormal').length,
|
||||
pending: tv.filter((v) => v.status === 'Pending').length,
|
||||
operating: tv.filter((v) => isOperatingVehicle(v)).length,
|
||||
inventory: tv.filter((v) => isInventoryVehicle(v)).length,
|
||||
abnormal: tv.filter((v) => isInventoryAbnormal(v)).length,
|
||||
pending: tv.filter((v) => isInventoryPending(v)).length,
|
||||
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
});
|
||||
const known = KNOWN.map((type) => make(type, vList.filter((v) => v.type === type)));
|
||||
@@ -295,37 +300,21 @@ app.get('/region-stats', async (c) => {
|
||||
.map(([city, cv]) => ({
|
||||
city,
|
||||
totalAssets: cv.length,
|
||||
operatingCount: cv.filter((v) => v.status === 'Operating').length,
|
||||
pendingCount: cv.filter((v) => v.status === 'Pending').length,
|
||||
operatingCount: cv.filter((v) => isOperatingVehicle(v)).length,
|
||||
pendingCount: cv.filter((v) => isInventoryPending(v)).length,
|
||||
customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
typeBreakdown: getTypeBreakdown(cv),
|
||||
}))
|
||||
.sort((a, b) => b.totalAssets - a.totalAssets);
|
||||
|
||||
// Top 8 cities + merge rest into "其他"
|
||||
const topCities = allCities.slice(0, 8);
|
||||
const restCities = allCities.slice(8);
|
||||
if (restCities.length > 0) {
|
||||
const restVehicles = restCities.flatMap((c) => {
|
||||
const key = c.city;
|
||||
return cityMap.get(key) || [];
|
||||
});
|
||||
topCities.push({
|
||||
city: '其他',
|
||||
totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0),
|
||||
operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0),
|
||||
pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0),
|
||||
customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
typeBreakdown: getTypeBreakdown(restVehicles),
|
||||
});
|
||||
}
|
||||
const cities = topCities;
|
||||
// The UI uses collapsible city sections, so keep actual cities addressable for drill-down.
|
||||
const cities = allCities;
|
||||
|
||||
return {
|
||||
region,
|
||||
totalAssets: allVehicles.length,
|
||||
operatingCount: allVehicles.filter((v) => v.status === 'Operating').length,
|
||||
pendingCount: allVehicles.filter((v) => v.status === 'Pending').length,
|
||||
operatingCount: allVehicles.filter((v) => isOperatingVehicle(v)).length,
|
||||
pendingCount: allVehicles.filter((v) => isInventoryPending(v)).length,
|
||||
customers,
|
||||
typeBreakdown: getTypeBreakdown(allVehicles),
|
||||
cities,
|
||||
@@ -338,7 +327,7 @@ app.get('/region-stats', async (c) => {
|
||||
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
|
||||
app.get('/customer-stats', async (c) => {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const operating = vehicles.filter((v) => isOperatingVehicle(v));
|
||||
|
||||
const custMap = new Map<string, Vehicle[]>();
|
||||
for (const v of operating) {
|
||||
@@ -368,16 +357,18 @@ app.get('/customer-stats', async (c) => {
|
||||
// GET /api/vehicles/list — flat list with optional filters
|
||||
app.get('/list', async (c) => {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
|
||||
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
|
||||
|
||||
let filtered = vehicles;
|
||||
let filtered = c.req.query('source') === 'region'
|
||||
? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v))
|
||||
: vehicles;
|
||||
|
||||
// attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles)
|
||||
if (attendance === 'active' || attendance === 'idle') {
|
||||
const realtimeRows = await vehicleRepository.getAttendanceMileageRows();
|
||||
const todayMap = new Map<string, number>();
|
||||
for (const row of realtimeRows as any[]) todayMap.set((row.plate_number || '').trim(), Number(row.day_mileage) || 0);
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
filtered = filtered.filter((v) => isOperatingVehicle(v));
|
||||
if (attendance === 'active') {
|
||||
filtered = filtered.filter((v) => (todayMap.get(v.plateNumber) || 0) > 0);
|
||||
} else {
|
||||
@@ -385,7 +376,9 @@ app.get('/list', async (c) => {
|
||||
}
|
||||
}
|
||||
if (vehicleType) {
|
||||
if (VEHICLE_TYPE_FILTERS[vehicleType]) {
|
||||
if (vehicleType === '区域其他' && c.req.query('source') === 'region') {
|
||||
filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type));
|
||||
} else if (VEHICLE_TYPE_FILTERS[vehicleType]) {
|
||||
filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]);
|
||||
} else if (vehicleType === '4.5T') {
|
||||
filtered = filtered.filter((v) => v.type === '4.5T');
|
||||
@@ -393,6 +386,9 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.type === vehicleType);
|
||||
}
|
||||
}
|
||||
if (brand) {
|
||||
filtered = filtered.filter((v) => (v.brandLabel || '未知') === brand);
|
||||
}
|
||||
if (batch && batch !== 'All') {
|
||||
filtered = filtered.filter((v) => (v.contractNo || '未知') === batch);
|
||||
}
|
||||
@@ -407,13 +403,13 @@ app.get('/list', async (c) => {
|
||||
}
|
||||
if (category) {
|
||||
if (category === 'Inventory') {
|
||||
filtered = filtered.filter((v) => v.status === 'Inventory');
|
||||
filtered = filtered.filter((v) => isInventoryVehicle(v));
|
||||
} else if (category === 'Abnormal') {
|
||||
filtered = filtered.filter((v) => v.status === 'Abnormal');
|
||||
filtered = filtered.filter((v) => isInventoryAbnormal(v));
|
||||
} else if (category === 'Operating') {
|
||||
filtered = filtered.filter((v) => v.status === 'Operating');
|
||||
filtered = filtered.filter((v) => isOperatingVehicle(v));
|
||||
} else if (category === 'Pending') {
|
||||
filtered = filtered.filter((v) => v.status === 'Pending');
|
||||
filtered = filtered.filter((v) => isInventoryPending(v));
|
||||
}
|
||||
}
|
||||
if (manager) {
|
||||
@@ -446,6 +442,9 @@ app.get('/list', async (c) => {
|
||||
type: v.type,
|
||||
model: v.model,
|
||||
location: v.location,
|
||||
color: v.color,
|
||||
operationStatus: v.operationStatus,
|
||||
inventoryKey: inventoryKey(inventoryDimensions(v)),
|
||||
province: v.province,
|
||||
city: v.city,
|
||||
status: v.status,
|
||||
@@ -453,6 +452,8 @@ app.get('/list', async (c) => {
|
||||
rentCompany: v.rentCompany,
|
||||
contractNo: v.contractNo,
|
||||
customerName: v.customerName,
|
||||
abnormalMove: v.abnormalMove ?? null,
|
||||
pendingDelivery: v.pendingDelivery ?? null,
|
||||
subjectOrg: v.subjectOrg,
|
||||
departmentName: v.departmentName,
|
||||
customerManager: v.customerManager,
|
||||
@@ -462,40 +463,34 @@ app.get('/list', async (c) => {
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
|
||||
// Inventory keeps its existing aggregate visibility; vehicle details retain user permissions.
|
||||
const INVENTORY_TYPE_NAMES: Record<string, string> = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' };
|
||||
function inventoryDimensions(v: Vehicle) {
|
||||
return { region: mapMacroRegion(v.province, v.city), city: resolveCity(v.city, v.province), brand: v.brandLabel || '未知', type: INVENTORY_TYPE_NAMES[classifyVehicleType({ ...v, model: v.model || "" })], model: v.model || "未标注型号" };
|
||||
}
|
||||
app.get('/inventory-stats', async (c) => {
|
||||
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
|
||||
const inventory = vehicles.filter((v) => v.status === 'Inventory');
|
||||
|
||||
const TYPE_NAME_MAP: Record<string, string> = {
|
||||
t4_5: '4.5T普货',
|
||||
t4_5c: '4.5T冷链',
|
||||
t18: '18T',
|
||||
t49: '49T',
|
||||
trailer: '挂车',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const groups = new Map<string, number>();
|
||||
for (const v of inventory) {
|
||||
const typeCategory = classifyVehicleType(v);
|
||||
const typeName = TYPE_NAME_MAP[typeCategory];
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
const city = resolveCity(v.city, v.province);
|
||||
const brand = v.brandLabel || '未知';
|
||||
const model = v.model;
|
||||
const key = `${region}|${city}|${brand}|${typeName}|${model}`;
|
||||
groups.set(key, (groups.get(key) || 0) + 1);
|
||||
const groups = new Map<string, ReturnType<typeof inventoryDimensions> & InventoryCounts & { batch: string; variants: Record<string, InventoryCounts> }>();
|
||||
for (const v of vehicles.filter(isInventoryVehicle)) {
|
||||
const dimensions = inventoryDimensions(v);
|
||||
const key = inventoryKey(dimensions);
|
||||
const row = groups.get(key) || { ...dimensions, batch: v.model, ...emptyInventoryCounts(), variants: {} };
|
||||
const color = inventoryColor(v.color);
|
||||
const variant = row.variants[color] || emptyInventoryCounts();
|
||||
for (const count of [row, variant]) {
|
||||
count.quantity += 1;
|
||||
count[inventoryOperation(v.operationStatus)] += 1;
|
||||
if (isInventoryPending(v)) count.pending += 1;
|
||||
if (isInventoryAbnormal(v)) {
|
||||
count.abnormal += 1;
|
||||
const type = v.abnormalMove?.type || '未提供';
|
||||
count.abnormalTypes[type] = (count.abnormalTypes[type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
row.variants[color] = variant;
|
||||
groups.set(key, row);
|
||||
}
|
||||
|
||||
const result = Array.from(groups.entries())
|
||||
.map(([key, quantity]) => {
|
||||
const [region, city, brand, type, model] = key.split('|');
|
||||
return { region, city, brand, type, model, batch: model, quantity };
|
||||
})
|
||||
.sort((a, b) => b.quantity - a.quantity);
|
||||
|
||||
return c.json(result);
|
||||
return c.json([...groups.values()].sort((a, b) => b.quantity - a.quantity));
|
||||
});
|
||||
|
||||
// GET /api/vehicles/weekly-detail?type=delivered|returned|replaced|pending
|
||||
@@ -514,7 +509,7 @@ app.get('/weekly-detail', async (c) => {
|
||||
const hasModelFilter = model && model !== 'All';
|
||||
const hasBatchFilter = batch && batch !== 'All';
|
||||
const hasLocationFilter = location && location !== 'All';
|
||||
if (hasModelFilter || hasBatchFilter || hasLocationFilter) {
|
||||
if (hasModelFilter || hasBatchFilter || hasLocationFilter || getSubjectParam(c)) {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
let pool2 = vehicles;
|
||||
if (hasModelFilter) pool2 = pool2.filter((v) => v.model === model);
|
||||
@@ -590,8 +585,8 @@ app.get('/subjects', async (c) => {
|
||||
if (!map.has(name)) map.set(name, { total: 0, inventory: 0, operating: 0 });
|
||||
const s = map.get(name)!;
|
||||
s.total += 1;
|
||||
if (v.status === 'Inventory') s.inventory += 1;
|
||||
if (v.status === 'Operating') s.operating += 1;
|
||||
if (isInventoryVehicle(v)) s.inventory += 1;
|
||||
if (isOperatingVehicle(v)) s.operating += 1;
|
||||
}
|
||||
|
||||
const result = Array.from(map.entries())
|
||||
@@ -622,7 +617,7 @@ app.get('/debug', async (c) => {
|
||||
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
|
||||
app.get('/region-chart', async (c) => {
|
||||
const vehicles = await getVehiclesForUser(c);
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const operating = vehicles.filter((v) => isOperatingVehicle(v));
|
||||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province'
|
||||
const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle'
|
||||
const top = Number(c.req.query('top')) || 8;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Vehicle } from '../../types.js';
|
||||
import { REGIONS, classifyVehicleType } from './model.js';
|
||||
import { REGIONS, classifyVehicleType, isOperatingVehicle, isInventoryVehicle, isInventoryAbnormal, isInventoryPending } from './model.js';
|
||||
import type { DateRange, VehicleStats, VehicleTypeCounts, WeeklyTruckIds } from './types.js';
|
||||
|
||||
export function countByType(vehicles: Vehicle[]): VehicleTypeCounts {
|
||||
@@ -28,14 +28,14 @@ export function getRegionCounts(vehicles: Vehicle[], regions: readonly string[])
|
||||
|
||||
export function getStats(list: Vehicle[], weeklyIds?: WeeklyTruckIds): VehicleStats {
|
||||
const ids = list.map((vehicle) => String(vehicle.id));
|
||||
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory');
|
||||
const inventory = list.filter(isInventoryVehicle);
|
||||
return {
|
||||
total: list.length,
|
||||
inventory: inventory.length,
|
||||
abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length,
|
||||
abnormal: list.filter(isInventoryAbnormal).length,
|
||||
inventoryRegions: getRegionCounts(inventory, REGIONS),
|
||||
pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
|
||||
operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
|
||||
pending: list.filter(isInventoryPending).length,
|
||||
operating: list.filter(isOperatingVehicle).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,
|
||||
|
||||
Reference in New Issue
Block a user