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

181 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model';
import type { RegionalInventoryStats } from '../../types';
import { inventoryKey } from '../../../../shared/assets/inventory';
const BRAND_ORDER = ['现代', '帕力安', '宇通', '飞驰', '跃进'];
const normalizeBrand = (brand: string) => brand.trim().replace(/牌$/, '');
function compareInventoryBrand(left: string, right: string) {
const rank = (brand: string) => {
const normalized = normalizeBrand(brand);
if (!normalized || normalized === '其他') return BRAND_ORDER.length + 1;
const index = BRAND_ORDER.indexOf(normalized);
return index < 0 ? BRAND_ORDER.length : index;
};
return rank(left) - rank(right);
}
export function inventoryTotal(rows: RegionalInventoryStats[]) {
return rows.reduce((total, row) => total + row.quantity, 0);
}
/** Decompose display text only; never change the model used for drilldown. */
export function inventoryModelTags(row: Pick<RegionalInventoryStats, 'type' | 'model' | 'batch'> & Partial<Pick<RegionalInventoryStats, 'brand'>>) {
const tags: { kind: 'type' | 'model' | 'detail' | 'batch'; label: string; value: string }[] = [];
const type = inventoryTypeLabel(row.type.trim());
const model = row.model.trim();
const details = Array.from(model.matchAll(/[((]([^()()]+)[))]/g), match => match[1].trim()).filter(Boolean);
const base = model.replace(/[((]([^()()]+)[))]/g, '').trim();
// The type already has its own tag, so avoid repeating it in the model tag.
let compactModel = (type ? base.replace(type, '') : base).trim();
// Alias model names can say “现代” even when the separate brand is “帕力安牌”.
// Strip only a leading brand, preserving body style, color and qualifiers.
const brands = [...new Set([row.brand || '', normalizeBrand(row.brand || ''), ...BRAND_ORDER, '帕力安牌'])]
.filter(brand => brand && brand !== '其他').sort((a, b) => b.length - a.length);
const prefix = brands.find(brand => compactModel.startsWith(brand));
if (prefix) compactModel = compactModel.slice(prefix.length).replace(/^[\s-]+/, '').trim();
if (type) tags.push({ kind: 'type', label: '车型', value: type });
if (compactModel) tags.push({ kind: 'model', label: '型号', value: compactModel });
for (const value of new Set(details)) tags.push({ kind: 'detail', label: '型号说明', value });
const batch = row.batch?.trim();
// The current API aliases batch to model; only render genuinely distinct values.
if (batch && batch !== model) tags.push({ kind: 'batch', label: '批次', value: batch });
return tags;
}
/** Preserve the displayed scope when opening a filtered total or group. */
export function inventorySelection(filters: InventoryFilters, title: string, overrides: Partial<VehicleModalSelection> = {}): VehicleModalSelection {
return {
batch: 'All', model: filters.model || 'All',
location: filters.city || filters.region || 'All',
brand: filters.brand || undefined, vehicleType: inventoryTypeLabel(filters.type) === '其他' ? '库存其他' : filters.type || undefined,
category: 'Inventory', source: 'asset', title, ...overrides,
};
}
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string, category: 'Inventory' | 'Abnormal' = 'Inventory'): VehicleModalSelection {
const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : '';
const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · ');
return {
batch: 'All', model: 'All', location: 'All', source: 'asset', category,
inventoryKeys: [...new Set(rows.map(inventoryKey))],
title: `库存统计 · ${scope}`, scopeLabel: category === 'Abnormal'
? `${scope} · 异动 ${rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0)} 台`
: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
};
}
export interface InventoryDisplayGroup {
name: string;
sections: [string, RegionalInventoryStats[]][];
rows: RegionalInventoryStats[];
quantity: number;
}
/** Keep each category's regions contiguous so only the first city renders its region cell. */
export function inventoryRegionRows(rows: RegionalInventoryStats[]) {
const regions = new Map<string, RegionalInventoryStats[]>();
for (const row of rows) {
const records = regions.get(row.region) || [];
records.push(row);
regions.set(row.region, records);
}
return [...regions.values()].flatMap(records => records.map((row, index) => ({
row, regionSpan: index === 0 ? records.length : 0,
})));
}
/** Group presentation only: keep each original record for quantity and drilldown. */
export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 'type' | 'model') {
const brands = new Map<string, { brand: string; rows: RegionalInventoryStats[]; variants: Map<string, RegionalInventoryStats[]> }>();
for (const row of rows) {
const key = normalizeBrand(row.brand);
let brand = brands.get(key);
if (!brand) {
brand = { brand: row.brand, rows: [], variants: new Map() };
brands.set(key, brand);
}
brand.rows.push(row);
const variantKey = groupBy === 'type' ? inventoryTypeLabel(row.type) : JSON.stringify([row.type, row.model, row.batch]);
const variant = brand.variants.get(variantKey) || [];
variant.push(row);
brand.variants.set(variantKey, variant);
}
return [...brands.values()].sort((a, b) => compareInventoryBrand(a.brand, b.brand))
.map(brand => ({ ...brand, variants: [...brand.variants.values()] }));
}
export function inventoryMobileRows(group: InventoryDisplayGroup, tab: 'region' | 'model') {
const sections = tab === 'region' ? group.sections : [[group.name, group.rows] as const];
return sections.flatMap(([, rows]) => inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model')
.flatMap(brand => brand.variants.flatMap(variants => tab === 'model'
? inventoryRegionRows(variants).map(({ row }) => row) : variants)));
}
/** Each level is a subtotal within its parent, across all pages of the filtered list. */
export function inventoryHierarchy(row: RegionalInventoryStats, rows: RegionalInventoryStats[], tab: 'region' | 'model') {
const cityRows = rows.filter(item => item.region === row.region && item.city === row.city);
const brandRows = (tab === 'region' ? cityRows : rows).filter(item => normalizeBrand(item.brand) === normalizeBrand(row.brand));
const variantRows = brandRows.filter(item => tab === 'region'
? inventoryTypeLabel(item.type) === inventoryTypeLabel(row.type)
: item.type === row.type && item.model === row.model && item.batch === row.batch);
const levels = tab === 'region'
? [{ label: row.city || '未分配', rows: cityRows }, { label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryTypeLabel(row.type) || '未分类', rows: variantRows }]
: [{ label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') || '未分类', rows: variantRows }, { label: row.region || '未分配', rows: variantRows.filter(item => item.region === row.region) }];
return levels.map(level => ({ ...level, quantity: inventoryTotal(level.rows) }));
}
export type InventoryMobileDimension = 'region' | 'city' | 'type' | 'variant';
/** One dimension per screen. Every child retains exact records from its parent. */
export function inventoryMobileGroups(rows: RegionalInventoryStats[], dimension: InventoryMobileDimension) {
const groups = new Map<string, { key: string; label: string; rows: RegionalInventoryStats[] }>();
for (const row of rows) {
const key = dimension === 'variant'
? JSON.stringify([normalizeBrand(row.brand), row.type, row.model, row.batch])
: dimension === 'type' ? inventoryTypeLabel(row.type) : row[dimension];
const qualifiers = dimension === 'variant' ? inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') : '';
const label = dimension === 'variant'
? [row.brand || '未分配品牌', qualifiers || row.model || '未分配型号'].join(' · ')
: key || (dimension === 'type' ? '未分类车型' : `未分配${dimension === 'city' ? '城市' : '区域'}`);
const group = groups.get(key) || { key, label, rows: [] };
group.rows.push(row);
groups.set(key, group);
}
return [...groups.values()].map(group => ({ ...group, quantity: inventoryTotal(group.rows) }));
}
export function inventoryMobileDrilldown(rows: RegionalInventoryStats[], tab: 'region' | 'model', path: string[]) {
const dimensions: InventoryMobileDimension[] = tab === 'region' ? ['city', 'type', 'variant'] : ['region', 'city', 'variant'];
const parents: ReturnType<typeof inventoryMobileGroups> = [];
let currentRows = rows;
for (const key of path.slice(0, dimensions.length - 1)) {
const parent = inventoryMobileGroups(currentRows, dimensions[parents.length]).find(group => group.key === key);
if (!parent) break;
parents.push(parent);
currentRows = parent.rows;
}
const dimension = dimensions[parents.length];
return { parents, rows: currentRows, dimension, groups: inventoryMobileGroups(currentRows, dimension) };
}
export function inventoryPage<T>(rows: T[], requestedPage: number) {
const pageSize = 6;
const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
const page = Math.max(0, Math.min(requestedPage, pageCount - 1));
return { page, pageCount, start: page * pageSize, rows: rows.slice(page * pageSize, (page + 1) * pageSize) };
}
export function inventoryGroups(source: Record<string, Record<string, RegionalInventoryStats[]>>, sort: 'default' | 'quantity', groupBy: 'region' | 'model' = 'region'): InventoryDisplayGroup[] {
const groups = Object.entries(source).map(([name, children]) => {
const sections: InventoryDisplayGroup['sections'] = Object.entries(children).map(([section, records]) => [
section, [...records].sort((a, b) => compareInventoryBrand(a.brand, b.brand)),
]);
// Keep city blocks intact; in the type view order whole model blocks by brand.
if (groupBy === 'model') sections.sort(([, a], [, b]) => compareInventoryBrand(a[0]?.brand || '', b[0]?.brand || ''));
const rows = sections.flatMap(([, records]) => records);
return { name, sections, rows, quantity: inventoryTotal(rows) };
});
return sort === 'quantity' ? groups.sort((a, b) => b.quantity - a.quantity) : groups;
}