feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user