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.
209 lines
11 KiB
TypeScript
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']);
|
|
});
|