feat: improve asset inventory statistics and responsive UX (v1.1.16)

This commit is contained in:
shishengliang
2026-09-23 11:07:36 +08:00
parent 9b0980e349
commit b0cba26fbb
30 changed files with 626 additions and 498 deletions
+4
View File
@@ -17,6 +17,10 @@ export function maskCustomerNames<T>(items: T[]): T[] {
const copy = { ...obj };
if ('customer' in copy && copy.customer) copy.customer = maskCustomerName(copy.customer);
if ('customerName' in copy && copy.customerName) copy.customerName = maskCustomerName(copy.customerName);
if (copy.pendingDelivery) copy.pendingDelivery = {
...copy.pendingDelivery,
customerName: maskCustomerName(copy.pendingDelivery.customerName),
};
return copy as T;
});
}
+44 -34
View File
@@ -13,6 +13,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 +67,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 +168,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 +184,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 +207,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 +256,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 +276,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,8 +299,8 @@ 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),
}))
@@ -324,8 +328,8 @@ app.get('/region-stats', async (c) => {
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 +342,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) {
@@ -377,7 +381,7 @@ app.get('/list', async (c) => {
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 {
@@ -407,13 +411,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) {
@@ -453,6 +457,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,
@@ -465,7 +471,7 @@ app.get('/list', async (c) => {
// GET /api/vehicles/inventory-stats — 库存统计,不设数据权限,对所有人开放
app.get('/inventory-stats', async (c) => {
const vehicles = applySubjectFilter(c, await vehicleRepository.getVehicles());
const inventory = vehicles.filter((v) => v.status === 'Inventory');
const inventory = vehicles.filter((v) => isInventoryVehicle(v));
const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货',
@@ -476,7 +482,7 @@ app.get('/inventory-stats', async (c) => {
other: '其他',
};
const groups = new Map<string, number>();
const groups = new Map<string, { quantity: number; abnormal: number; pending: number }>();
for (const v of inventory) {
const typeCategory = classifyVehicleType(v);
const typeName = TYPE_NAME_MAP[typeCategory];
@@ -485,13 +491,17 @@ app.get('/inventory-stats', async (c) => {
const brand = v.brandLabel || '未知';
const model = v.model;
const key = `${region}|${city}|${brand}|${typeName}|${model}`;
groups.set(key, (groups.get(key) || 0) + 1);
const counts = groups.get(key) || { quantity: 0, abnormal: 0, pending: 0 };
counts.quantity += 1;
if (isInventoryAbnormal(v)) counts.abnormal += 1;
if (isInventoryPending(v)) counts.pending += 1;
groups.set(key, counts);
}
const result = Array.from(groups.entries())
.map(([key, quantity]) => {
.map(([key, counts]) => {
const [region, city, brand, type, model] = key.split('|');
return { region, city, brand, type, model, batch: model, quantity };
return { region, city, brand, type, model, batch: model, ...counts };
})
.sort((a, b) => b.quantity - a.quantity);
@@ -590,8 +600,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 +632,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;
@@ -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);
});
+56 -8
View File
@@ -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');
});
+7 -5
View File
@@ -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,
+35
View File
@@ -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.车辆类型参数),
+33
View File
@@ -37,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 车辆品牌,
@@ -114,6 +123,30 @@ 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'
-- 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'`;
+5 -5
View File
@@ -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,
+13
View File
@@ -1,4 +1,15 @@
import type { AbnormalMoveDetail } from '../shared/assets/abnormal-move.js';
export interface VehicleRow {
pending_delivery_id?: string | null;
pending_contract_no?: string | null;
pending_customer_name?: string | null;
abnormal_move_id?: string | null;
abnormal_move_type?: number | null;
abnormal_move_status?: number | null;
abnormal_move_work?: string | null;
abnormal_move_target_time?: string | null;
abnormal_move_actual_time?: string | null;
id: string;
车牌号: string;
vin: string;
@@ -30,6 +41,8 @@ export interface VehicleRow {
}
export interface Vehicle {
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
abnormalMove?: AbnormalMoveDetail | null;
id: string;
plateNumber: string;
vin: string;