Files
ln-bi/src/modules/assets/analytics-model.test.ts
T
shishengliang f1b3f8d3da
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): add dimension analysis and authorized user previews
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.
2026-09-28 12:29:51 +08:00

209 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import type { AssetAnalysisCell } from '../../shared/assets/analytics';
import {
aggregateAnalysisRows,
filterAnalysisCells,
filterCustomCells,
getAnalysisDimensions,
getAnalysisSlice,
getAvailableDimensions,
getCustomAnalysis,
getDefaultDimensions,
summarizeAnalyticsCells,
} from './analytics-model';
function cell(overrides: Partial<AssetAnalysisCell>): AssetAnalysisCell {
return {
type: '18T', model: '车型甲', region: '华东', city: '嘉兴市',
assetStatus: 'inventory', assetDisposition: '在库', operationKind: '可运营',
inventoryState: 'available', abnormalType: '不适用', abnormalAge: 'unknown',
department: '业务一部', manager: '负责人甲', customer: '客户甲', quantity: 1,
...overrides,
};
}
const cells = [
cell({ quantity: 5 }),
cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: '30d+', quantity: 3 }),
cell({ region: '华南', city: '广州市', inventoryState: 'pending', quantity: 2 }),
cell({ assetStatus: 'operating', assetDisposition: '租赁', operationKind: '租赁', inventoryState: 'not-applicable', quantity: 10 }),
cell({ type: '49T', region: '华南', city: '深圳市', assetStatus: 'operating', assetDisposition: '自营', operationKind: '自营', inventoryState: 'not-applicable', quantity: 4 }),
cell({ assetStatus: 'other', assetDisposition: '其他', operationKind: '未分类', inventoryState: 'not-applicable', quantity: 1 }),
];
test('inventory subset counts reconcile at group and filtered-total levels without adding to inventory', () => {
assert.ok(!getAvailableDimensions('inventory').includes('manager'));
assert.ok(!getAvailableDimensions('inventory').includes('customer'));
assert.ok(getAvailableDimensions('operating').includes('manager'));
assert.ok(getAvailableDimensions('operating').includes('customer'));
const result = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'] });
assert.equal(result.total, 10);
assert.equal(result.summary.pending, 2);
assert.equal(result.summary.abnormal, 3);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.pending, 0), 2);
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.abnormal, 0), 3);
const filtered = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'], filterValues: { region: ['华东'] } });
assert.equal(filtered.total, 8);
assert.equal(filtered.summary.pending, 0);
assert.equal(filtered.summary.abnormal, 3);
assert.equal(filtered.rows[0].totals.pending, 0);
assert.equal(filtered.rows[0].totals.abnormal, 3);
});
test('management totals reconcile without double-counting inventory subsets', () => {
const summary = summarizeAnalyticsCells(cells);
assert.equal(summary.assets, 25);
assert.equal(summary.operating, 14);
assert.equal(summary.inventory, 10);
assert.equal(summary.other, 1);
assert.equal(summary.abnormal, 3);
assert.equal(summary.pending, 2);
assert.equal(summary.abnormalRate, 30);
assert.equal(summary.pendingRate, 20);
assert.ok(Math.abs(summary.operatingRate - 56) < 1e-10);
});
test('operating drill keeps region, city, type and operation kind as aggregate levels', () => {
let slice = getAnalysisSlice(cells, { view: 'operating' });
assert.equal(slice.dimension, 'region');
assert.equal(slice.total, 14);
assert.deepEqual(slice.rows.map(row => row.count), [10, 4]);
assert.equal(slice.rows.reduce((sum, row) => sum + row.share, 0), 100);
for (const expectedDimension of ['city', 'type', 'operationKind']) {
assert.equal(slice.isLeaf, false);
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.dimension, expectedDimension);
assert.equal(slice.total, 10);
}
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
assert.equal(slice.isLeaf, true);
assert.equal(slice.dimension, null);
assert.deepEqual(slice.rows, []);
assert.equal(summarizeAnalyticsCells(filterAnalysisCells(cells, slice.filters, slice.metric)).assets, slice.total);
});
test('asset uses mutually exclusive stock, lease and self-operated dispositions', () => {
const slice = getAnalysisSlice(cells, { view: 'assets', filters: { type: '18T' } });
assert.equal(slice.dimension, 'assetDisposition');
assert.equal(slice.rows.reduce((sum, row) => sum + row.count, 0), slice.total);
assert.deepEqual(slice.rows.map(row => row.value).sort(), ['其他', '在库', '租赁'].sort());
});
test('fixed global dimensions are skipped and conflicting stale paths cannot widen results', () => {
const slice = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南', type: '49T' },
path: [{ dimension: 'region', value: '华南' }, { dimension: 'city', value: '深圳市' }],
});
assert.equal(slice.dimension, 'operationKind');
assert.equal(slice.total, 4);
assert.deepEqual(slice.path, [{ dimension: 'city', value: '深圳市' }]);
const conflict = getAnalysisSlice(cells, {
view: 'operating', filters: { region: '华南' }, path: [{ dimension: 'region', value: '华东' }],
});
assert.equal(conflict.total, 0);
assert.deepEqual(conflict.rows, []);
});
test('abnormal pivot retains all three dimensions once and ends at vehicle type', () => {
for (const start of ['abnormalType', 'region', 'abnormalAge'] as const) {
const dimensions = getAnalysisDimensions('abnormal', start);
assert.equal(dimensions[0], start);
assert.equal(dimensions.at(-1), 'type');
assert.equal(new Set(dimensions).size, dimensions.length);
}
const missingTime = cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: 'unknown', quantity: 2 });
const slice = getAnalysisSlice([...cells, missingTime], { view: 'abnormal', abnormalStart: 'abnormalAge' });
assert.equal(slice.total, 5);
assert.equal(slice.rows.find(row => row.value === 'unknown')?.label, '审批时间缺失');
assert.equal(slice.rows.find(row => row.value === 'unknown')?.count, 2);
});
test('chronological label sort preserves all duration buckets without rounding totals', () => {
const durationCells = ['unknown', '30d+', '0-3d', '15-30d', '7-15d', '3-7d'].map(abnormalAge => cell({
inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'],
}));
const rows = aggregateAnalysisRows(durationCells, 'abnormalAge', 'abnormal', 'label');
assert.deepEqual(rows.map(row => row.value), ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown']);
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), 6);
assert.equal(summarizeAnalyticsCells([]).operatingRate, 0);
});
test('custom analysis combines independent dimensions and conserves the selected metric', () => {
const analysis = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'] });
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.total, 25);
assert.equal(analysis.rows.length, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.count, 19);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '18T')?.count, 2);
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '49T')?.count, 4);
assert.ok(Math.abs(analysis.rows.reduce((sum, row) => sum + row.share, 0) - 100) < 1e-10);
});
test('custom composite keys cannot collide when dimension values contain delimiters', () => {
const collisionCells = [
cell({ region: 'A · B', city: 'C', quantity: 2 }),
cell({ region: 'A', city: 'B · C', quantity: 3 }),
cell({ region: '["A","B"]', city: '"C"', quantity: 4 }),
];
const analysis = getCustomAnalysis(collisionCells, { view: 'assets', dimensions: ['region', 'city'] });
assert.equal(analysis.rows.length, 3);
assert.equal(new Set(analysis.rows.map(row => row.key)).size, 3);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 9);
});
test('custom filters OR values within a dimension and AND across dimensions', () => {
const filterValues = { region: ['华东', '华南'], type: ['18T'], inventoryState: ['available', 'pending'] };
const filtered = filterCustomCells(cells, filterValues, 'inventory');
assert.equal(summarizeAnalyticsCells(filtered).inventory, 7);
const analysis = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues });
assert.equal(analysis.total, 7);
assert.deepEqual(analysis.rows.map(row => row.count), [5, 2]);
assert.equal(analysis.rows[0].share, 5 / 7 * 100);
assert.equal(getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues: { region: ['不存在区域'] } }).total, 0);
});
test('empty filter selections impose no restriction and filter-only analysis gives one total', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: [], filterValues: { region: [] } });
assert.equal(analysis.total, 14);
assert.equal(analysis.rows.length, 1);
assert.equal(analysis.rows[0].label, '合计');
assert.equal(analysis.rows[0].count, 14);
assert.equal(analysis.rows[0].share, 100);
assert.deepEqual(analysis.rows[0].values, {});
assert.equal(analysis.summary.assets, 25);
assert.equal(getCustomAnalysis([], { view: 'operating', dimensions: [] }).rows.length, 0);
assert.equal(getCustomAnalysis([cell({})], { view: 'operating', dimensions: [] }).rows.length, 0);
});
test('custom shares use the selected metric rather than all assets in the scope', () => {
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: ['region'] });
assert.equal(analysis.total, 14);
assert.equal(analysis.summary.assets, 25);
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.share, 10 / 14 * 100);
assert.equal(analysis.rows.find(row => row.values.region === '华南')?.share, 4 / 14 * 100);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
});
test('custom dimensions remove unknown, duplicate and inapplicable values without losing counts', () => {
const analysis = getCustomAnalysis(cells, {
view: 'operating',
dimensions: ['region', 'bogus', 'region', 'abnormalAge', 'type'] as Parameters<typeof getCustomAnalysis>[1]['dimensions'],
});
assert.deepEqual(analysis.dimensions, ['region', 'type']);
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 14);
for (const view of ['assets', 'operating', 'inventory', 'abnormal', 'pending', 'department', 'customer'] as const) {
assert.equal(getDefaultDimensions(view).length, 1);
assert.ok(getAvailableDimensions(view).includes(getDefaultDimensions(view)[0]));
}
});
test('custom sorting supports count order and semantic duration order', () => {
const ascending = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'], sort: 'count-asc' });
assert.deepEqual(ascending.rows.map(row => row.count), [2, 4, 19]);
const durationCells = ['unknown', '30d+', '0-3d'].map(abnormalAge => cell({ inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'] }));
const byLabel = getCustomAnalysis(durationCells, { view: 'abnormal', dimensions: ['abnormalAge'], sort: 'label' });
assert.deepEqual(byLabel.rows.map(row => row.values.abnormalAge), ['0-3d', '30d+', 'unknown']);
});