feat(assets): align flow statistics and separate abnormal inventory
ci/woodpecker/push/woodpecker Pipeline failed

Use operations signing times and delivery completion statuses; include replacement deliveries in delivery totals with range drilldowns and exports. Separate abnormal vehicles from inventory, add a date range picker, and enable CI for feature/asset-statistics.
This commit is contained in:
shishengliang
2026-09-16 10:35:50 +08:00
parent 6ea1b3d5dd
commit 0c26b973ef
21 changed files with 371 additions and 119 deletions
+10 -5
View File
@@ -70,7 +70,7 @@ app.get('/summary', async (c) => {
hanging: 0,
},
inventory: {
total: vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal').length,
total: vehicles.filter((v) => v.status === 'Inventory').length,
inStock: vehicles.filter((v) => v.status === 'Inventory').length,
abnormal: vehicles.filter((v) => v.status === 'Abnormal').length,
},
@@ -111,6 +111,7 @@ app.get('/by-type', async (c) => {
type: t.name,
totalAssets: typeVehicles.length,
totalInventory: typeStats.inventory,
totalAbnormal: typeStats.abnormal,
totalOperating: typeStats.operating,
inventoryRegions: typeStats.inventoryRegions,
pending: typeStats.pending,
@@ -273,6 +274,7 @@ app.get('/region-stats', async (c) => {
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,
customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[],
});
@@ -405,7 +407,9 @@ app.get('/list', async (c) => {
}
if (category) {
if (category === 'Inventory') {
filtered = filtered.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal');
filtered = filtered.filter((v) => v.status === 'Inventory');
} else if (category === 'Abnormal') {
filtered = filtered.filter((v) => v.status === 'Abnormal');
} else if (category === 'Operating') {
filtered = filtered.filter((v) => v.status === 'Operating');
} else if (category === 'Pending') {
@@ -461,7 +465,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' || v.status === 'Abnormal');
const inventory = vehicles.filter((v) => v.status === 'Inventory');
const TYPE_NAME_MAP: Record<string, string> = {
t4_5: '4.5T普货',
@@ -525,7 +529,7 @@ app.get('/weekly-detail', async (c) => {
});
// GET /api/vehicles/flow-stats?start=YYYY-MM-DD&end=YYYY-MM-DD
// 资产流转日报:按提交时间(create_time)统计交车、还车、替换车,并返回可点击明细。
// 资产流转日报:按运维签章时间统计业务车次;交车包含关联替换单的记录,替换作为其中的子项,同车多笔业务分别累计。
app.get('/flow-stats', async (c) => {
const { start, end } = normalizeDateRange(c.req.query('start'), c.req.query('end'));
const allowedVehicles = await getVehiclesForUser(c);
@@ -555,6 +559,7 @@ app.get('/flow-stats', async (c) => {
const stat = dailyMap.get(item.date);
if (!stat) continue;
stat[item.type] += 1;
if (item.type === 'replaced') stat.delivered += 1;
stat.total += 1;
}
@@ -585,7 +590,7 @@ 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' || v.status === 'Abnormal') s.inventory += 1;
if (v.status === 'Inventory') s.inventory += 1;
if (v.status === 'Operating') s.operating += 1;
}
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import router from '../vehicles.js';
import { vehicleRepository, type FlowDetailRow } from './repository.js';
import type { Vehicle } from '../../types.js';
test('replacement trips are included in delivery counts without inflating totals or duplicating export rows', async (t) => {
t.mock.method(vehicleRepository, 'getVehicles', async () => [{ id: '1' } as Vehicle]);
const row = (id: string, type: FlowDetailRow['type'], date: string): FlowDetailRow => ({
id, type, type_label: type === 'replaced' ? '替换交车' : type === 'delivered' ? '交车' : '还车',
stat_date: date, truck_id: '1', plate_number: '测试车牌',
event_time: `${date} 12:00:00`, submit_time: '2026-09-01 12:00:00',
department: null, manager: null, customer_name: null,
});
t.mock.method(vehicleRepository, 'getFlowDetailRows', async () => [
row('delivered-1', 'delivered', '2026-09-15'),
row('replaced-2', 'replaced', '2026-09-15'),
row('returned-1', 'returned', '2026-09-15'),
row('replaced-3', 'replaced', '2026-09-16'),
]);
const response = await router.request('/flow-stats?start=2026-09-15&end=2026-09-17');
assert.equal(response.status, 200);
const data = await response.json();
assert.deepEqual(data.totals, { delivered: 3, returned: 1, replaced: 2, total: 4 });
assert.deepEqual(data.daily, [
{ date: '2026-09-15', delivered: 2, returned: 1, replaced: 1, total: 3 },
{ date: '2026-09-16', delivered: 1, returned: 0, replaced: 1, total: 1 },
{ date: '2026-09-17', delivered: 0, returned: 0, replaced: 0, total: 0 },
]);
assert.equal(data.details.length, 4);
assert.equal(new Set(data.details.map((x: { id: string }) => x.id)).size, 4);
assert.ok(data.details.filter((x: { type: string }) => x.type === 'replaced')
.every((x: { typeLabel: string }) => x.typeLabel === '替换交车'));
});
test('inventory excludes abnormal vehicles in totals, type rollups, 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: '广东',
subjectOrg: i === 1 ? '乙公司' : '甲公司',
} 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 get = async (path: string) => {
const response = await router.request(path);
assert.equal(response.status, 200);
return response.json();
};
const summary = await get('/summary');
assert.equal(summary.totalAssets, 4);
assert.equal(summary.inventory.total, 1);
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.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: { 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);
const inventory = await get('/inventory-stats');
assert.equal(inventory.reduce((n: number, row: { quantity: number }) => n + row.quantity, 0), 1);
});
+3 -2
View File
@@ -213,8 +213,9 @@ test('preserves inventory, weekly-flow and mileage aggregation boundaries', () =
});
assert.deepEqual(getStats(vehicles, weeklyIds), {
total: 4,
inventory: 2,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 1, 新疆: 0, 其他: 0 },
inventory: 1,
abnormal: 1,
inventoryRegions: { 嘉兴: 1, 广东: 0, 北京: 0, 新疆: 0, 其他: 0 },
pending: 1,
operating: 1,
weeklyDelivered: 1,
+46 -1
View File
@@ -110,7 +110,52 @@ test('keeps weekly detail SQL selection and flow date parameters unchanged', asy
{ 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'],
values: ['2026-08-01', '2026-08-13', '2026-08-01', '2026-08-13'],
},
]);
});
test('flow dates and inclusive day boundaries use operations signing times, retaining creation time only for display', () => {
for (const [alias, field] of [['dv', 'delivery_completion_processed_time'], ['r', 'return_completion_processed_time']]) {
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.${field}, '%Y-%m-%d') AS stat_date`));
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} IS NOT NULL`));
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} >= ?`));
assert.ok(FLOW_STATS_SQL.includes(`AND ${alias}.${field} < DATE_ADD(?, INTERVAL 1 DAY)`));
assert.ok(FLOW_STATS_SQL.includes(`DATE_FORMAT(${alias}.create_time, '%Y-%m-%d %H:%i:%s') AS submit_time`));
}
assert.doesNotMatch(FLOW_STATS_SQL, /AND \w+\.create_time/);
assert.ok(FLOW_STATS_SQL.includes('ORDER BY flow.event_time DESC, flow.id DESC'));
});
test('flow retains multiple business events for the same vehicle', async () => {
const events = [
{ id: 'delivered-1', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
{ id: 'delivered-2', truck_id: '1', type: 'delivered', stat_date: '2026-09-16' },
{ id: 'returned-1', truck_id: '1', type: 'returned', stat_date: '2026-09-16' },
];
const repository = new VehicleRepository(new FakeDatabase([events]));
assert.deepEqual(await repository.getFlowDetailRows('2026-09-16', '2026-09-16'), events);
});
test('replacement trips come exclusively from linked delivery records', () => {
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type"));
assert.ok(FLOW_STATS_SQL.includes("CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label"));
assert.equal((FLOW_STATS_SQL.match(/FROM delivery_vehicle/g) || []).length, 1);
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', () => {
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 (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)'));
});
test('return history includes completed, signed and awaiting-signature statuses without arrival or settlement requirements', () => {
const returns = FLOW_STATS_SQL.split('UNION ALL')[1];
assert.ok(returns.includes('AND r.status IN (2,3,5)'));
assert.ok(returns.includes('AND r.return_completion_processed_time >= ?'));
assert.doesNotMatch(returns, /r\.is_arrived|r\.arrival_time|settlement|AND dts\.status|AND \(dts\.status/);
});
+18 -43
View File
@@ -231,13 +231,13 @@ 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,
CONCAT(CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced-' ELSE 'delivered-' END, dv.id) AS id,
CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN 'replaced' ELSE 'delivered' END AS type,
CASE WHEN dv.vehicle_replacement_id IS NOT NULL THEN '替换交车' ELSE '交车' END AS type_label,
DATE_FORMAT(dv.delivery_completion_processed_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.delivery_completion_processed_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,
@@ -251,10 +251,11 @@ export const FLOW_STATS_SQL = `
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)
AND dv.delivery_completion_processed_time IS NOT NULL
AND dv.delivery_status IN (2,3,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)
UNION ALL
@@ -262,10 +263,10 @@ export const FLOW_STATS_SQL = `
CONCAT('returned-', r.id) AS id,
'returned' AS type,
'还车' AS type_label,
DATE_FORMAT(r.create_time, '%Y-%m-%d') AS stat_date,
DATE_FORMAT(r.return_completion_processed_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.return_completion_processed_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,
@@ -279,40 +280,14 @@ export const FLOW_STATS_SQL = `
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.return_completion_processed_time IS NOT NULL
-- 与还车管理历史记录一致:完成、已签章、待签署;日期按运维签章时间归属。
AND r.status IN (2,3,5)
AND r.create_time >= ?
AND r.create_time < DATE_ADD(?, INTERVAL 1 DAY)
AND r.return_completion_processed_time >= ?
AND r.return_completion_processed_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
ORDER BY flow.event_time DESC, flow.id DESC
`;
const CACHE_TTL = 60 * 1000;
@@ -420,7 +395,7 @@ export class VehicleRepository {
}
async getFlowDetailRows(start: string, end: string): Promise<FlowDetailRow[]> {
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end, start, end]);
const [rows] = await this.db.query<any[]>(FLOW_STATS_SQL, [start, end, start, end]);
return rows as FlowDetailRow[];
}
+1
View File
@@ -12,6 +12,7 @@ export interface WeeklyTruckIds {
export interface VehicleStats {
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
+2 -1
View File
@@ -28,10 +28,11 @@ 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' || vehicle.status === 'Abnormal');
const inventory = list.filter((vehicle) => vehicle.status === 'Inventory');
return {
total: list.length,
inventory: inventory.length,
abnormal: list.filter((vehicle) => vehicle.status === 'Abnormal').length,
inventoryRegions: getRegionCounts(inventory, REGIONS),
pending: list.filter((vehicle) => vehicle.status === 'Pending').length,
operating: list.filter((vehicle) => vehicle.status === 'Operating').length,
+5
View File
@@ -81,6 +81,7 @@ export interface TypeSummary {
type: string;
totalAssets: number;
totalInventory: number;
totalAbnormal: number;
totalOperating: number;
inventoryRegions: Record<string, number>;
pending: number;
@@ -94,6 +95,7 @@ export interface ModelSummary {
model: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -107,6 +109,7 @@ export interface BatchSummary {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -119,6 +122,7 @@ export interface BatchGroup {
batch: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;
@@ -130,6 +134,7 @@ export interface BatchGroup {
type: string;
total: number;
inventory: number;
abnormal: number;
inventoryRegions: Record<string, number>;
pending: number;
operating: number;