feat(assets): refine compact inventory drilldowns and exact subtotal scopes

This commit is contained in:
shishengliang
2026-09-23 17:52:33 +08:00
parent d20bc71efe
commit b5a48b6417
15 changed files with 534 additions and 111 deletions
@@ -1,5 +1,6 @@
import type { InventoryFilters, VehicleModalSelection } from '../../model';
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(/牌$/, '');
@@ -21,7 +22,7 @@ export function inventoryTotal(rows: RegionalInventoryStats[]) {
/** 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 = row.type.trim();
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();
@@ -47,11 +48,21 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove
return {
batch: 'All', model: filters.model || 'All',
location: filters.city || filters.region || 'All',
brand: filters.brand || undefined, vehicleType: filters.type || undefined,
brand: filters.brand || undefined, vehicleType: inventoryTypeLabel(filters.type) === '其他' ? '库存其他' : filters.type || undefined,
category: 'Inventory', source: 'asset', title, ...overrides,
};
}
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): 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: 'Inventory',
inventoryKeys: [...new Set(rows.map(inventoryKey))],
title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
};
}
export interface InventoryDisplayGroup {
name: string;
sections: [string, RegionalInventoryStats[]][];
@@ -59,6 +70,19 @@ export interface InventoryDisplayGroup {
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[]> }>();
@@ -70,7 +94,7 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't
brands.set(key, brand);
}
brand.rows.push(row);
const variantKey = groupBy === 'type' ? row.type : JSON.stringify([row.type, row.model, row.batch]);
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);
@@ -79,6 +103,67 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't
.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]) => [