feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful

Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
@@ -0,0 +1,271 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import type { AuthUser } from '../../auth/types.js';
import type { Vehicle } from '../../types.js';
import type { AssetAnalysisResponse, AssetAnalysisScope } from '../../../shared/assets/analytics.js';
import app from '../vehicles.js';
import { vehicleRepository } from './repository.js';
import {
abnormalAgeBucket, abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis,
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
type ApprovalTimes,
} from './analytics.js';
const NOW = Date.parse('2026-09-28T12:00:00+08:00');
const approvals: ApprovalTimes = { available: true, byMoveId: new Map() };
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
id, plateNumber: `TEST-${id}`, vin: `PRIVATE-VIN-${id}`, type: '4.5T', model: '现代4.5T普货',
color: '白色', location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
status: 'Operating', operationStatus: '1', ownership: 'Self', rentCompany: '',
contractNo: '合同甲', customerName: '测试客户公司', orgName: null, departmentName: '部门甲',
subjectOrg: '公司甲', projectName: null, customerManager: '负责人甲', managerId: 'manager-a',
brandLabel: '现代', ...overrides,
});
test('duration buckets use exact approval-time boundaries and reject missing or future dates', () => {
const since = (days: number) => new Date(NOW - days * 86400000).toISOString();
for (const [days, expected] of [[0, '0-3d'], [2.999, '0-3d'], [3, '3-7d'], [6.999, '3-7d'], [7, '7-15d'], [14.999, '7-15d'], [15, '15-30d'], [29.999, '15-30d'], [30, '30d+'], [90, '30d+']] as const) {
assert.equal(abnormalAgeBucket(since(days), NOW), expected);
}
for (const invalid of [undefined, '', 'not-a-date', since(-1)]) {
assert.equal(abnormalAgeBucket(invalid, NOW), 'unknown');
assert.equal(abnormalElapsedDays(invalid, NOW), null);
}
assert.equal(abnormalElapsedDays('2026-09-27T12:00:00+08:00', NOW), 1);
assert.equal(abnormalAgeBucket('2026-09-25T04:00:00Z', NOW), '3-7d');
});
test('asset dispositions reconcile to total while abnormal and pending stay inventory subsets', () => {
const data = [
make('leased'), make('self', { operationStatus: '2' }),
make('available', { operationStatus: '3', status: 'Inventory' }),
make('abnormal', { operationStatus: '3', status: 'Abnormal' }),
make('pending', { operationStatus: '4', status: 'Pending' }),
make('unknown', { operationStatus: null, status: 'Inventory' }),
];
const result = buildAssetAnalysis(data, approvals, NOW);
assert.equal(result.summary.totalAssets, 6);
assert.equal(result.summary.operating.total, 2);
assert.equal(result.summary.operating.leased, 1);
assert.equal(result.summary.operating.self, 1);
assert.deepEqual(result.summary.inventory, { total: 3, inStock: 1, abnormal: 1 });
assert.equal(result.summary.pendingDelivery, 1);
assert.equal(result.summary.otherAssets, 1);
assert.equal(result.cells.reduce((sum, row) => sum + row.quantity, 0), 6);
const count = (metric: 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending') => result.cells.filter(cell => matchesAnalysisMetric(cell, metric)).reduce((sum, row) => sum + row.quantity, 0);
assert.deepEqual(['assets', 'operating', 'inventory', 'abnormal', 'pending'].map(metric => count(metric as Parameters<typeof count>[0])), [6, 2, 3, 1, 1]);
assert.equal(analysisCellForVehicle(data[0], approvals, NOW).assetDisposition, '租赁');
assert.equal(analysisCellForVehicle(data[1], approvals, NOW).assetDisposition, '自营');
assert.equal(analysisCellForVehicle(data[2], approvals, NOW).assetDisposition, '在库');
assert.equal(analysisCellForVehicle(data[5], approvals, NOW).assetDisposition, '其他');
});
test('one grouping keeps municipality, cold-chain and other-type detail filters consistent', () => {
const first = make('1', { province: '上海市', city: '松江区', model: '现代4.5T冷链' });
const second = make('2', { province: '上海市', city: '浦东新区', model: '现代4.5T冷链' });
const result = buildAssetAnalysis([first, second, make('3', { type: '挂车', model: '挂车' })], approvals, NOW);
const cold = result.cells.find(cell => cell.type === '4.5T冷链')!;
assert.equal(cold.quantity, 2);
assert.equal(cold.city, '上海市');
assert.equal(result.cells.find(cell => cell.type === '其他')?.quantity, 1);
assert.equal(matchesAnalysisFilters(cold, { region: '华东', city: '上海市', type: '4.5T冷链' }), true);
assert.equal(matchesAnalysisFilters(cold, { region: '华南', city: '上海市' }), false);
});
test('analysis regions and cities exactly match inventory statistics and retain unmapped locations', async (t) => {
const stock = (id: string, province: string | null, city: string | null) => make(id, {
operationStatus: '3', status: 'Inventory', province, city,
// This legacy field intentionally disagrees with coordinates: neither API may use it.
location: '广东', region: '广东',
});
const vehicles = [
stock('east-1', '上海市', '松江区'), stock('east-2', '上海市', '浦东新区'),
stock('south', '广东省', '广州市'), stock('northwest', '新疆', '乌鲁木齐市'),
stock('southwest', '四川省', '成都市'), stock('north', '北京市', '朝阳区'),
stock('central', '湖北省', '武汉市'), stock('unmapped', null, null),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const inventoryResponse = await app.request('/inventory-stats?subject=公司甲');
const analysisResponse = await app.request('/analysis?subject=公司甲');
assert.equal(inventoryResponse.status, 200);
assert.equal(analysisResponse.status, 200);
const inventory = await inventoryResponse.json() as Array<{
region: string; city: string; brand: string; type: string; model: string; quantity: number;
}>;
const analysis = await analysisResponse.json() as AssetAnalysisResponse;
const locations = (rows: Array<{ region: string; city: string; quantity: number }>) => rows
.map(row => ({ region: row.region, city: row.city, quantity: row.quantity }))
.sort((a, b) => `${a.region}:${a.city}`.localeCompare(`${b.region}:${b.city}`));
assert.deepEqual(locations(analysis.cells), locations(inventory));
assert.equal(analysis.summary.inventory.total, vehicles.length);
for (const name of ['华东', '华南', '西北', '西南']) assert.ok(analysis.cells.some(cell => cell.region === name));
// The inventory mapper also supports these regions. Do not arbitrarily reassign or discard them.
assert.ok(analysis.cells.some(cell => cell.region === '华北'));
assert.ok(analysis.cells.some(cell => cell.region === '华中'));
const unknown = analysis.cells.find(cell => cell.region === '其他');
assert.equal(unknown?.quantity, 1);
assert.equal(unknown?.city, '其他');
for (const row of inventory) {
const key = JSON.stringify([row.region, row.city, row.brand, row.type, row.model]);
const scopedResponse = await app.request('/analysis?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryKeys: [key] }),
});
assert.equal(scopedResponse.status, 200);
const scoped = await scopedResponse.json() as AssetAnalysisResponse;
assert.equal(scoped.summary.inventory.total, row.quantity);
assert.ok(scoped.cells.every(cell => cell.region === row.region && cell.city === row.city));
const details = await app.request('/analysis/details?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: 'inventory', inventoryKeys: [key], filters: { region: row.region, city: row.city } }),
});
assert.equal(details.status, 200);
assert.equal((await details.json() as unknown[]).length, row.quantity);
}
});
test('abnormal classification never substitutes target or actual completion for approval time', () => {
const vehicle = make('1', {
operationStatus: '3', status: 'Abnormal', abnormalMove: {
id: 'move-1', type: '维修', work: 'PRIVATE-WORK', targetCompletionTime: '2026-09-01 12:00',
actualCompletionTime: '2026-09-27 12:00', initiatingDepartment: null, executingDepartment: null,
responsiblePerson: null, driver: null, status: '待结束',
},
});
assert.equal(analysisCellForVehicle(vehicle, approvals, NOW).abnormalAge, 'unknown');
const history = { available: true, byMoveId: new Map([['move-1', '2026-09-21T12:00:00+08:00']]) };
assert.equal(analysisCellForVehicle(vehicle, history, NOW).abnormalAge, '7-15d');
const noWorkflow = buildAssetAnalysis([vehicle], { available: false, byMoveId: new Map() }, NOW);
assert.equal(noWorkflow.approvalTimeAvailable, false);
assert.equal(noWorkflow.summary.inventory.abnormal, 1);
});
test('scope and dimensional filter validation rejects unknown keys, arrays and malformed enums', () => {
assert.equal(validAnalysisScope({ inventoryKeys: [], subject: null, attendance: 'idle', isColdChain: 'false' }), true);
assert.equal(validAnalysisFilters({ region: '华东', assetDisposition: '在库', abnormalAge: 'unknown' }), true);
for (const bad of [null, [], 'all', { managerId: 'other' }, { inventoryKeys: [1] }, { inventoryKeys: null }, { category: 'anything' }, { attendance: 'all' }, { isTrailer: false }, { isColdChain: 'yes' }, { subject: 4 }]) {
assert.equal(validAnalysisScope(bad), false, JSON.stringify(bad));
}
for (const bad of [null, [], { quantity: 10 }, { region: ['华东'] }, { unknown: 'value' }]) {
assert.equal(validAnalysisFilters(bad), false, JSON.stringify(bad));
}
});
test('multi-value filters validate known dimensions and string arrays without changing single-value filters', () => {
for (const valid of [{}, { region: [] }, { region: ['华东', '华南'], type: ['4.5T普货', '18T'] }]) {
assert.equal(validAnalysisFilterValues(valid), true);
}
for (const invalid of [undefined, null, [], 'all', { region: '华东' }, { region: [1] }, { region: ['华东', null] }, { region: [['华东']] }, { region: null }, { region: undefined }, { notADimension: [] }]) {
assert.equal(validAnalysisFilterValues(invalid), false, JSON.stringify(invalid));
}
assert.equal(validAnalysisFilters({ region: ['华东', '华南'] }), false);
});
test('multi-value alternatives are ORed inside a dimension and ANDed with row filters', () => {
const cell = analysisCellForVehicle(make('1'), approvals, NOW);
assert.equal(matchesAnalysisFilterValues(cell, {}), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: [], type: [] }), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T', '4.5T普货'] }), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T'] }), false);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华南'], type: ['4.5T普货'] }), false);
const multiMatches = matchesAnalysisFilterValues(cell, { region: ['华东', '华南'] });
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华东' }), true);
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华南' }), false);
});
test('detail multi-select preserves permissions, company, scope and a clicked row intersection', async (t) => {
const vehicles = [
make('east-light'), make('east-heavy', { type: '18T', model: '飞驰18T' }),
make('south-light', { province: '广东省', city: '广州市' }),
make('south-heavy', { province: '广东省', city: '广州市', type: '18T', model: '飞驰18T' }),
make('hidden-user', { managerId: 'manager-b' }),
make('hidden-company', { subjectOrg: '公司乙' }),
make('hidden-department', { departmentName: '部门乙' }),
make('inventory', { operationStatus: '3', status: 'Inventory' }),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const secured = new Hono<{ Variables: { user: AuthUser } }>();
secured.use('*', async (c, next) => {
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
await next();
});
secured.route('/', app);
const request = (extra: Record<string, unknown>) => secured.request('/analysis/details?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: 'operating', scope: { department: '部门甲' }, filters: {}, ...extra }),
});
const ids = async (extra: Record<string, unknown>) => {
const response = await request(extra);
assert.equal(response.status, 200);
return (await response.json() as Array<{ id: string }>).map(row => row.id);
};
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货', '18T'] } }), ['east-light', 'east-heavy', 'south-light', 'south-heavy']);
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货'] }, filters: { region: '华东' } }), ['east-light']);
assert.deepEqual(await ids({ filterValues: { region: ['华南'] }, filters: { region: '华东' } }), []);
assert.deepEqual(await ids({ filterValues: { region: [], type: [] }, filters: { type: '18T' } }), ['east-heavy', 'south-heavy']);
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'] }, scope: { subject: '公司乙' } }), []);
for (const invalid of [null, [], { region: '华东' }, { region: [1] }, { managerId: ['manager-b'] }]) {
assert.equal((await request({ filterValues: invalid })).status, 400);
}
assert.equal((await request({ filters: { region: ['华东'] } })).status, 400);
});
test('analysis and final details intersect company, permissions and exact entry scope without leaking row fields', async (t) => {
const vehicles = [make('visible'), make('hidden', { managerId: 'manager-b' }), make('other-company', { subjectOrg: '公司乙' })];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const secured = new Hono<{ Variables: { user: AuthUser } }>();
secured.use('*', async (c, next) => {
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
await next();
});
secured.route('/', app);
const post = (path: string, body: unknown) => secured.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const response = await post('/analysis?subject=公司甲', { scope: { model: '现代4.5T普货' } });
assert.equal(response.status, 200);
const aggregate = await response.json() as AssetAnalysisResponse;
assert.equal(aggregate.summary.totalAssets, 1);
const serialized = JSON.stringify(aggregate);
for (const secret of ['PRIVATE-VIN', 'TEST-visible', 'manager-a', 'manager-b', '测试客户公司', 'plateNumber', 'managerId', 'contractNo']) {
assert.equal(serialized.includes(secret), false, `aggregate leaked ${secret}`);
}
const details = await post('/analysis/details?subject=公司甲', { metric: 'operating', filters: { region: '华东' }, generatedAt: aggregate.generatedAt, scope: { model: '现代4.5T普货' } });
assert.equal(details.status, 200);
assert.deepEqual((await details.json() as Array<{ id: string }>).map(row => row.id), ['visible']);
const excluded = await post('/analysis?subject=公司甲', { scope: { subject: '公司乙' } });
assert.equal((await excluded.json() as AssetAnalysisResponse).summary.totalAssets, 0);
});
test('analysis inherits legacy list filtering including attendance and empty inventory scopes', async (t) => {
const rows = [
make('1'), make('2', { model: '现代4.5T冷链' }),
make('3', { operationStatus: '3', status: 'Inventory' }),
make('4', { type: '挂车', model: '挂车' }),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => rows);
t.mock.method(vehicleRepository, 'getAttendanceMileageRows', async () => [
{ plate_number: 'TEST-1', day_mileage: 10 }, { plate_number: 'TEST-2', day_mileage: 0 },
]);
const scopes: AssetAnalysisScope[] = [
{ source: 'region', location: '华东' }, { vehicleType: '4.5T冷链', category: 'Operating' },
{ department: '部门甲', manager: '负责人甲', attendance: 'active' },
{ isTrailer: 'true' }, { isColdChain: 'false', brand: '现代', batch: '合同甲' },
{ subject: ' 公司甲 ' }, { inventoryKeys: [] },
];
for (const scope of scopes) {
const { inventoryKeys, ...params } = scope;
const query = new URLSearchParams(Object.entries(params).filter((pair): pair is [string, string] => typeof pair[1] === 'string'));
const listResponse = await app.request(`/list?${query}`, inventoryKeys ? {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }),
} : undefined);
const analysisResponse = await app.request('/analysis', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }),
});
assert.equal(analysisResponse.status, 200);
assert.equal((await analysisResponse.json() as AssetAnalysisResponse).summary.totalAssets, (await listResponse.json() as unknown[]).length, JSON.stringify(scope));
}
for (const body of [{ scope: { inventoryKeys: [42] } }, { scope: { unauthorizedField: 'x' } }, { inventoryKeys: 'all' }]) {
assert.equal((await app.request('/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).status, 400);
}
});
+181
View File
@@ -0,0 +1,181 @@
import pool from '../../db.js';
import type { Vehicle } from '../../types.js';
import {
ASSET_ANALYSIS_DIMENSIONS,
type AbnormalAgeBucket,
type AssetAnalysisCell,
type AssetAnalysisFilters,
type AssetAnalysisFilterValues,
type AssetAnalysisMetric,
type AssetAnalysisResponse,
type AssetAnalysisScope,
} from '../../../shared/assets/analytics.js';
import {
isInventoryAbnormal, isInventoryPending, isInventoryVehicle, isOperatingVehicle,
mapMacroRegion, resolveCity,
} from './model.js';
export interface ApprovalTimes {
available: boolean;
byMoveId: Map<string, string>;
}
/**
* Verified workflow source: movement = 异动审批; business_id is the movement id.
* Only a PASS task reaching an end node in a finished instance counts as approval.
* flow_his_task.update_time is documented as 审批完成时间. Movement start/create
* timestamps are intentionally never substituted when workflow history is absent.
*/
export async function loadMovementApprovalTimes(vehicles: Vehicle[]): Promise<ApprovalTimes> {
const ids = [...new Set(vehicles.filter(isInventoryAbnormal).flatMap(v => v.abnormalMove?.id ? [v.abnormalMove.id] : []))];
if (!ids.length) return { available: true, byMoveId: new Map() };
try {
const [rows] = await pool.query(`
SELECT f.business_id AS move_id,
DATE_FORMAT(MAX(h.update_time), '%Y-%m-%dT%H:%i:%s+08:00') AS approved_at
FROM \`ry-workflow\`.flow_instance f
JOIN \`ry-workflow\`.flow_definition d ON d.id = f.definition_id AND d.flow_code = 'movement'
JOIN \`ry-workflow\`.flow_his_task h ON h.instance_id = f.id
AND h.del_flag = '0' AND h.skip_type = 'PASS' AND h.flow_status IN ('pass', '2')
JOIN \`ry-workflow\`.flow_node n ON n.definition_id = f.definition_id
AND n.node_code = h.target_node_code AND n.node_type = 2 AND n.del_flag = '0'
WHERE f.del_flag = '0' AND f.flow_status IN ('finish', '8')
AND f.business_id IN (${ids.map(() => '?').join(',')})
GROUP BY f.business_id`, ids);
return {
available: true,
byMoveId: new Map((rows as Array<{ move_id: string; approved_at: string | null }> )
.filter(row => row.approved_at).map(row => [String(row.move_id), row.approved_at!])),
};
} catch {
// Workflow access is optional; totals remain usable and missing ages stay explicit.
return { available: false, byMoveId: new Map() };
}
}
export function abnormalElapsedDays(approvedAt: string | undefined, now: number): number | null {
const timestamp = approvedAt ? Date.parse(approvedAt) : NaN;
return Number.isFinite(timestamp) && timestamp <= now ? (now - timestamp) / 86400000 : null;
}
export function abnormalAgeBucket(approvedAt: string | undefined, now: number): AbnormalAgeBucket {
const days = abnormalElapsedDays(approvedAt, now);
if (days === null) return 'unknown';
if (days < 3) return '0-3d';
if (days < 7) return '3-7d';
if (days < 15) return '7-15d';
if (days < 30) return '15-30d';
return '30d+';
}
export function analysisCellForVehicle(vehicle: Vehicle, approvals: ApprovalTimes, now: number): AssetAnalysisCell {
const operating = isOperatingVehicle(vehicle);
const inventory = isInventoryVehicle(vehicle);
const abnormal = isInventoryAbnormal(vehicle);
const pending = isInventoryPending(vehicle);
const operation = (vehicle.operationStatus || '').trim();
const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[operation] || operation || '未分类';
const inventoryState = !inventory ? 'not-applicable' : abnormal ? 'abnormal' : pending ? 'pending'
: operationKind === '可运营' ? 'available' : operationKind === '待运营' ? 'awaiting' : 'unclassified';
return {
type: vehicle.type === '4.5T' ? (vehicle.model.includes('冷链') ? '4.5T冷链' : '4.5T普货')
: ['18T', '49T'].includes(vehicle.type) ? vehicle.type : '其他',
model: vehicle.model || '未标注型号',
// Match /inventory-stats inventoryDimensions exactly: the legacy vehicle.location
// field uses a different, older region taxonomy and must not drive BI drill-down.
// Keep the mapper's unknown bucket so missing locations never disappear.
region: mapMacroRegion(vehicle.province, vehicle.city),
city: resolveCity(vehicle.city, vehicle.province),
assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other',
assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : operationKind === '自营' ? '自营' : '其他运营') : '其他',
operationKind,
inventoryState,
abnormalType: abnormal ? (vehicle.abnormalMove?.type || '未提供') : '不适用',
abnormalAge: abnormal ? abnormalAgeBucket(approvals.byMoveId.get(vehicle.abnormalMove?.id || ''), now) : 'unknown',
department: vehicle.model === '公务车/挂靠车' ? '公务车' : vehicle.departmentName || '未分配部门',
manager: vehicle.customerManager || '未分配',
customer: vehicle.customerName || '未分配客户',
quantity: 1,
};
}
export function matchesAnalysisMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
if (metric === 'assets') return true;
if (metric === 'operating') return cell.assetStatus === 'operating';
if (metric === 'inventory') return cell.assetStatus === 'inventory';
if (metric === 'abnormal') return cell.inventoryState === 'abnormal';
return cell.inventoryState === 'pending';
}
export function matchesAnalysisFilters(cell: AssetAnalysisCell, filters: AssetAnalysisFilters): boolean {
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => filters[dimension] === undefined || cell[dimension] === filters[dimension]);
}
export function matchesAnalysisFilterValues(cell: AssetAnalysisCell, filterValues: AssetAnalysisFilterValues): boolean {
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => {
const values = filterValues[dimension];
return !values?.length || values.includes(cell[dimension]);
});
}
export function buildAssetAnalysis(vehicles: Vehicle[], approvals: ApprovalTimes, now: number): AssetAnalysisResponse {
const groups = new Map<string, AssetAnalysisCell>();
for (const vehicle of vehicles) {
const cell = analysisCellForVehicle(vehicle, approvals, now);
const key = JSON.stringify(ASSET_ANALYSIS_DIMENSIONS.map(dimension => cell[dimension]));
const existing = groups.get(key);
if (existing) existing.quantity += 1;
else groups.set(key, cell);
}
const operating = vehicles.filter(isOperatingVehicle);
const inventory = vehicles.filter(isInventoryVehicle);
return {
generatedAt: new Date(now).toISOString(),
approvalTimeAvailable: approvals.available,
summary: {
totalAssets: vehicles.length,
operating: {
total: operating.length,
self: operating.filter(v => ['2', '自营'].includes((v.operationStatus || '').trim())).length,
leased: operating.filter(v => ['1', '租赁'].includes((v.operationStatus || '').trim())).length,
public: operating.filter(v => v.ownership === 'Public').length,
hanging: 0,
},
inventory: {
total: inventory.length,
inStock: inventory.filter(v => v.status === 'Inventory').length,
abnormal: inventory.filter(isInventoryAbnormal).length,
},
pendingDelivery: inventory.filter(isInventoryPending).length,
otherAssets: vehicles.length - operating.length - inventory.length,
},
cells: [...groups.values()].sort((a, b) => b.quantity - a.quantity),
};
}
export function validAnalysisFilters(value: unknown): value is AssetAnalysisFilters {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, item]) => ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number]) && typeof item === 'string');
}
export function validAnalysisFilterValues(value: unknown): value is AssetAnalysisFilterValues {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, values]) =>
ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number])
&& Array.isArray(values) && values.every(item => typeof item === 'string'));
}
export function validAnalysisScope(value: unknown): value is AssetAnalysisScope {
const keys = new Set(['brand', 'batch', 'model', 'location', 'status', 'category', 'vehicleType', 'manager', 'customer', 'department', 'attendance', 'isColdChain', 'isTrailer', 'source', 'subject']);
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, item]) => {
if (key === 'inventoryKeys') return Array.isArray(item) && item.every(entry => typeof entry === 'string');
if (!keys.has(key)) return false;
if (key === 'subject' && item === null) return true;
if (typeof item !== 'string') return false;
if (key === 'category') return ['Inventory', 'Abnormal', 'Operating', 'Pending'].includes(item);
if (key === 'attendance') return ['active', 'idle'].includes(item);
if (key === 'isColdChain' || key === 'isTrailer') return ['true', 'false'].includes(item);
return true;
});
}
@@ -39,6 +39,7 @@ test('keeps all vehicle GET endpoints in their established order', () => {
'/dept-stats',
'/region-stats',
'/customer-stats',
'/analysis',
'/list',
'/inventory-stats',
'/weekly-detail',