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 & Partial>) { 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 { 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(); 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 }>(); 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(); 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 = []; 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(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>, 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; }