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,208 @@
|
||||
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']);
|
||||
});
|
||||
Reference in New Issue
Block a user