feat(assets): align flow statistics and separate abnormal inventory
ci/woodpecker/push/woodpecker Pipeline failed
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface WeeklyTruckIds {
|
||||
export interface VehicleStats {
|
||||
total: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
inventoryRegions: Record<string, number>;
|
||||
pending: number;
|
||||
operating: number;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user