Files
ln-bi/src/modules/assets/components/overview/inventory-presentation.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

264 lines
17 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { buildVehicleModalRequest, deriveInventoryView, groupInventoryByRegion, type VehicleModalSelection } from '../../model';
import { selectInventoryGroup, selectInventoryRow } from './InventoryDesktopTable';
import type { RegionalInventoryStats } from '../../types';
import { inventoryBrandGroups, inventoryGroups, inventoryHierarchy, inventoryMobileDrilldown, inventoryMobileGroups, inventoryMobileRows, inventoryModelTags, inventoryPage, inventoryRegionRows, inventorySelection, inventorySubtotalSelection } from './inventory-presentation';
import { inventoryKey } from '../../../../shared/assets/inventory';
import { fetchVehicleList } from '../../api';
const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' };
test('abnormal inventory drill preserves exact row keys and intersects the abnormal category and company', () => {
const rows = [{ ...filters, batch: '', quantity: 10, abnormal: 1 }];
const selection = inventorySubtotalSelection(rows, rows[0].model, 'Abnormal');
const request = buildVehicleModalRequest(selection, '公司甲');
assert.equal(request.kind, 'vehicles');
if (request.kind !== 'vehicles') throw new Error('wrong endpoint');
assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey));
assert.equal(request.params.category, 'Abnormal');
assert.equal(request.params.subject, '公司甲');
assert.match(selection.scopeLabel!, /异动 1 台$/);
assert.ok(selection.scopeLabel!.includes('批次一'));
});
test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => {
const rows = [
{ ...filters, batch: '', quantity: 9 },
{ ...filters, brand: '品牌乙', batch: '', quantity: 8 },
{ ...filters, type: '49T', batch: '', quantity: 3 },
{ ...filters, city: '苏州', batch: '', quantity: 2 },
];
const cities = inventoryMobileDrilldown(rows, 'region', []);
assert.equal(cities.dimension, 'city');
assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 20], ['苏州', 2]]);
const types = inventoryMobileDrilldown(rows, 'region', ['上海']);
assert.equal(types.dimension, 'type');
assert.deepEqual(types.groups.map(group => [group.label, group.quantity]), [['18T', 17], ['49T', 3]]);
const variants = inventoryMobileDrilldown(rows, 'region', ['上海', '18T']);
assert.equal(variants.dimension, 'variant');
assert.deepEqual(variants.groups.map(group => group.quantity), [9, 8]);
assert.deepEqual(variants.groups.flatMap(group => inventorySubtotalSelection(group.rows, group.label).inventoryKeys), rows.slice(0, 2).map(inventoryKey));
assert.deepEqual(inventoryMobileDrilldown(rows, 'region', ['上海']).groups, types.groups, 'back navigation restores the whole parent');
});
test('mobile model navigation isolates same-named cities by region and recovers invalid paths', () => {
const rows = [
{ ...filters, batch: '', quantity: 4 },
{ ...filters, region: '华南', batch: '', quantity: 7 },
{ ...filters, region: '华南', city: '', batch: '', quantity: 1 },
];
assert.equal(inventoryMobileDrilldown(rows, 'model', []).dimension, 'region');
const cities = inventoryMobileDrilldown(rows, 'model', ['华南']);
assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 7], ['未分配城市', 1]]);
assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '上海']).rows, [rows[1]]);
assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '']).rows, [rows[2]]);
assert.equal(inventoryMobileDrilldown(rows, 'model', ['华南', '已移除城市']).dimension, 'city');
assert.equal(inventoryMobileDrilldown(rows, 'model', ['已移除区域']).dimension, 'region');
assert.deepEqual(inventoryMobileDrilldown([], 'model', []).groups, []);
});
test('mobile type totals combine brands and other types without merging distinct variants', () => {
const rows = [
{ ...filters, type: '挂车', brand: '帕力安牌', batch: '一批', quantity: 3 },
{ ...filters, type: '其他', brand: '帕力安', batch: '二批', quantity: 4 },
];
assert.deepEqual(inventoryMobileGroups(rows, 'type').map(group => [group.label, group.quantity]), [['其他', 7]]);
const view = inventoryMobileDrilldown(rows, 'region', ['上海', '其他']);
assert.equal(view.groups.length, 2);
assert.deepEqual(view.groups.flatMap(group => group.rows), rows);
});
test('subtotal drilldown sends the exact filtered records, including merged brand aliases and vehicle types', async (t) => {
const rows = [
{ ...filters, brand: '帕力安牌', type: '挂车', batch: '', quantity: 2 },
{ ...filters, brand: '帕力安', type: '其他', model: '其他型号', batch: '', quantity: 1 },
];
const selection = inventorySubtotalSelection(rows, '其他');
const request = buildVehicleModalRequest(selection, '公司甲');
assert.equal(request.kind, 'vehicles');
if (request.kind !== 'vehicles') throw new Error('wrong endpoint');
assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey));
assert.equal(request.params.subject, '公司甲');
assert.equal(request.params.category, 'Inventory');
assert.equal(request.params.brand, undefined, 'a merged alias must not restrict the scope to one spelling');
t.mock.method(globalThis, 'fetch', async (url: string, init?: RequestInit) => {
assert.equal(init?.method, 'POST');
assert.deepEqual(JSON.parse(String(init?.body)).inventoryKeys, rows.map(inventoryKey));
assert.equal(new URL(url, 'http://localhost').searchParams.get('subject'), '公司甲');
return new Response('[]', { status: 200 });
});
await fetchVehicleList(request.params);
});
test('hierarchy subtotals stay inside their parent and include records beyond the current page', () => {
const base = { ...filters, batch: '', quantity: 9 };
const rows = [base, { ...base, model: '批次二', quantity: 8 }, { ...base, type: '49T', quantity: 5 }, { ...base, brand: '品牌乙', quantity: 3 }, { ...base, city: '苏州', quantity: 2 }];
assert.deepEqual(inventoryHierarchy(base, rows, 'region').map(level => level.quantity), [25, 22, 17]);
const modelRows = rows.filter(row => row.type === '18T');
assert.deepEqual(inventoryHierarchy(base, modelRows, 'model').map(level => level.quantity), [19, 11, 11]);
assert.deepEqual(inventoryHierarchy(base, [base], 'region').map(level => level.quantity), [9, 9, 9]);
});
test('region merging brings separated cities together without losing their quantities or scope', () => {
const rows = [
{ ...filters, region: '华南', city: '广州', batch: '', quantity: 25 },
{ ...filters, batch: '', quantity: 9 },
{ ...filters, region: '华南', city: '佛山', batch: '', quantity: 1 },
];
const merged = inventoryRegionRows(rows);
assert.deepEqual(merged.map(({ row, regionSpan }) => [row.city, row.quantity, regionSpan]), [['广州', 25, 2], ['佛山', 1, 0], ['上海', 9, 1]]);
assert.deepEqual(rows.map(row => row.city), ['广州', '上海', '佛山']);
assert.equal(merged[1].row, rows[2]);
});
test('mobile pages cover every record once and clamp after a filter reduces the rows', () => {
const rows = Array.from({ length: 19 }, (_, index) => ({ ...filters, model: `型号${index}`, batch: '', quantity: index + 1 }));
const group = inventoryGroups(groupInventoryByRegion(rows), 'default')[0];
const ordered = inventoryMobileRows(group, 'region');
const pages = Array.from({ length: 4 }, (_, index) => inventoryPage(ordered, index));
assert.deepEqual(pages.map(page => page.rows.length), [6, 6, 6, 1]);
assert.deepEqual(pages.flatMap(page => page.rows), rows);
assert.equal(inventoryPage(rows.slice(0, 2), 3).page, 0);
assert.deepEqual(inventoryPage([], 1), { page: 0, pageCount: 1, start: 0, rows: [] });
});
test('inventory status hints show nonzero subset counts and hide zeros', () => {
const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 };
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [row] }));
assert.match(html, /待交车 <strong>2<\/strong> 台/);
assert.match(html, /异动 <strong>1<\/strong> 台/);
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
});
test('filtered inventory totals retain all filters in the vehicle request', () => {
const request = buildVehicleModalRequest(inventorySelection(filters, '筛选结果'), '公司甲');
assert.deepEqual(request, { kind: 'vehicles', params: {
brand: '品牌甲', vehicleType: '18T', model: '批次一', location: '上海',
source: 'asset', category: 'Inventory', subject: '公司甲',
} });
});
test('inventory subsets retain scope while changing category', () => {
for (const category of ['Pending', 'Abnormal'] as const) {
const request = buildVehicleModalRequest(inventorySelection({ ...filters, city: '' }, '子集', { category }), null);
assert.equal(request.kind, 'vehicles');
if (request.kind !== 'vehicles') throw new Error('wrong request type');
assert.equal(request.params.location, '华东');
assert.equal(request.params.category, category);
assert.equal(request.params.vehicleType, '18T');
assert.equal(request.params.brand, '品牌甲');
}
});
test('quantity sorting preserves source order and counts inventory subsets only once', () => {
const rows: RegionalInventoryStats[] = [
{ ...filters, batch: '', quantity: 8, pending: 2, abnormal: 1 },
{ ...filters, city: '苏州', batch: '', quantity: 3 },
{ ...filters, region: '华南', city: '广州', batch: '', quantity: 20 },
];
const source = groupInventoryByRegion(rows);
assert.deepEqual(inventoryGroups(source, 'quantity').map(group => [group.name, group.quantity]), [['华南', 20], ['华东', 11]]);
assert.deepEqual(inventoryGroups(source, 'default').map(group => group.name), ['华东', '华南']);
assert.deepEqual(inventoryGroups({}, 'quantity'), []);
});
test('model tags separate type and qualifiers without inventing a batch', () => {
assert.deepEqual(inventoryModelTags({ type: '4.5T冷链', model: '现代4.5T冷链(交投)', batch: '现代4.5T冷链(交投)' }), [
{ kind: 'type', label: '车型', value: '4.5T冷链' },
{ kind: 'detail', label: '型号说明', value: '交投' },
]);
assert.deepEqual(inventoryModelTags({ type: '18T', model: '飞驰18T(白车)', batch: '2026-01' }).map(tag => tag.value), ['18T', '白车', '2026-01']);
assert.deepEqual(inventoryModelTags({ type: '挂车', model: '挂车', batch: '挂车' }).map(tag => tag.value), ['其他', '挂车']);
assert.deepEqual(inventoryModelTags({ type: '', model: '未知型号(未闭合', batch: '' }).map(tag => tag.value), ['未知型号(未闭合']);
});
test('brand removal preserves body style, color and qualifiers in model tags', () => {
assert.deepEqual(inventoryModelTags({ brand: '帕力安牌', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '' }).map(tag => tag.value), ['18T', '双飞翼', '羚牛']);
assert.deepEqual(inventoryModelTags({ brand: '苏龙', type: '18T', model: '苏龙18T飞翼(白色)', batch: '' }).map(tag => tag.value), ['18T', '飞翼', '白色']);
assert.deepEqual(inventoryModelTags({ brand: '飞驰', type: '49T', model: '49T飞驰(红车)', batch: '' }).map(tag => tag.value), ['49T', '红车']);
});
test('brand ordering stays inside city blocks and does not mutate source rows', () => {
const brands = ['其他', '飞驰', '跃进', '帕力安牌', '宇通', '现代', '苏龙', '帕力安', '明威'];
const records = brands.map(brand => ({ ...filters, brand, batch: '', quantity: 1 }));
const source = { 华东: { 上海: records, 苏州: [{ ...records[0], city: '苏州' }] } };
const result = inventoryGroups(source, 'default');
assert.deepEqual(result[0].sections.map(([name]) => name), ['上海', '苏州']);
assert.deepEqual(result[0].sections[0][1].map(row => row.brand), ['现代', '帕力安牌', '帕力安', '宇通', '飞驰', '跃进', '苏龙', '明威', '其他']);
assert.deepEqual(records.map(row => row.brand), brands);
assert.equal(result[0].quantity, 10);
});
test('type view sorts complete model blocks by brand while preserving model grouping', () => {
const row = { ...filters, batch: '', quantity: 1 };
const source = { '18T': {
飞驰白车: [{ ...row, brand: '飞驰' }, { ...row, brand: '飞驰', city: '苏州' }],
现代双飞翼: [{ ...row, brand: '帕力安牌' }],
现代普货: [{ ...row, brand: '现代' }],
} };
const result = inventoryGroups(source, 'default', 'model');
assert.deepEqual(result[0].sections.map(([name]) => name), ['现代普货', '现代双飞翼', '飞驰白车']);
assert.equal(result[0].sections[2][1].length, 2);
});
test('brand and type groups combine nonadjacent variants while retaining all detail records', () => {
const rows: RegionalInventoryStats[] = [
{ ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '', quantity: 9 },
{ ...filters, brand: '现代', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '', quantity: 8 },
{ ...filters, brand: '飞驰', type: '49T', model: '49T飞驰', batch: '', quantity: 16 },
{ ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(红车)', batch: '', quantity: 7 },
];
const groups = inventoryBrandGroups(rows, 'type');
assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']);
assert.deepEqual(groups[1].variants.map(variant => variant.map(row => row.quantity)), [[9, 7], [16]]);
assert.equal(groups[1].variants[0][0], rows[0]);
assert.equal(groups[1].variants[0][1], rows[3]);
assert.equal(groups.flatMap(group => group.rows).reduce((sum, row) => sum + row.quantity, 0), 40);
assert.deepEqual(rows.map(row => row.quantity), [9, 8, 16, 7]);
assert.deepEqual(groups[1].variants[0].map(row => inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value)), [['白车'], ['红车']]);
});
test('model grouping merges locations but keeps different brands, colors and batches separate', () => {
const base = { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '一期', quantity: 1 };
const rows = [base, { ...base, city: '苏州' }, { ...base, batch: '二期' }, { ...base, model: '飞驰18T(红车)' }, { ...base, brand: '现代' }];
const groups = inventoryBrandGroups(rows, 'model');
assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']);
assert.deepEqual(groups[1].variants.map(variant => variant.length), [2, 1, 1]);
assert.deepEqual(groups[1].variants[0].map(row => row.city), ['上海', '苏州']);
for (const row of groups.flatMap(group => group.rows)) {
assert.equal(inventorySelection(row, '明细').model, row.model);
assert.equal(inventorySelection(row, '明细').brand, row.brand);
assert.equal(inventorySelection(row, '明细').location, row.city);
}
});
test('inventory other includes trailers in groups, filters and drilldown while retaining exact row types', () => {
const trailers = { ...filters, brand: '明威', type: '挂车', model: '挂车', batch: '挂车', quantity: 3, pending: 1 };
const other = { ...filters, brand: '其他', type: '其他', model: '氢能叉车', batch: '', quantity: 2, abnormal: 1 };
const standard = { ...filters, type: '18T', batch: '', quantity: 10 };
const filter = { region: '', city: '', brand: '', type: '其他', model: '' };
const view = deriveInventoryView([trailers, other, standard], filter, '其他');
assert.deepEqual(view.filtered, [trailers, other]);
assert.deepEqual(view.types, ['18T', '其他']);
assert.deepEqual(view.modelsForType, ['挂车', '氢能叉车']);
assert.deepEqual(Object.keys(view.byModel), ['其他']);
const group = inventoryGroups(view.byModel, 'default', 'model')[0];
assert.equal(group.quantity, 5);
assert.equal(group.rows.reduce((sum, row) => sum + (row.pending || 0), 0), 1);
assert.equal(group.rows.reduce((sum, row) => sum + (row.abnormal || 0), 0), 1);
let selected: VehicleModalSelection | null = null;
const select = (value: VehicleModalSelection | null | ((previous: VehicleModalSelection | null) => VehicleModalSelection | null)) => { selected = typeof value === 'function' ? value(selected) : value; };
selectInventoryGroup(group, { tab: 'model', filters: filter, select });
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '库存其他');
assert.equal(inventorySelection(filter, '筛选后库存').vehicleType, '库存其他');
selectInventoryRow(trailers, select);
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '挂车');
assert.equal((selected as VehicleModalSelection | null)?.model, '挂车');
selectInventoryRow(other, select);
assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '其他');
});