import type { AssetAnalysisCell, AssetAnalysisDimension, AssetAnalysisFilters, AssetAnalysisMetric, } from '../../shared/assets/analytics'; import { ASSET_ANALYSIS_DIMENSIONS } from '../../shared/assets/analytics'; export type AnalysisView = AssetAnalysisMetric | 'department' | 'customer'; export type AnalysisSort = 'count-desc' | 'count-asc' | 'label'; export type AbnormalStartDimension = 'abnormalType' | 'region' | 'abnormalAge'; export interface AnalysisPathStep { dimension: AssetAnalysisDimension; value: string; } export interface AnalysisTotals { assets: number; operating: number; inventory: number; abnormal: number; pending: number; other: number; /** All rates are percentages, not fractions. */ operatingRate: number; inventoryRate: number; /** Abnormal and pending are inventory subsets; their denominator is inventory. */ abnormalRate: number; pendingRate: number; } export interface AnalysisAggregateRow { value: string; label: string; count: number; /** Share of the current metric total, as a percentage. */ share: number; totals: AnalysisTotals; } export interface AnalysisRow extends AnalysisAggregateRow { nextPath: AnalysisPathStep[]; isLeaf: boolean; } export interface AnalysisOptions { view: AnalysisView; filters?: AssetAnalysisFilters; path?: readonly AnalysisPathStep[]; abnormalStart?: AbnormalStartDimension; sort?: AnalysisSort; } export interface AnalysisSlice { metric: AssetAnalysisMetric; dimension: AssetAnalysisDimension | null; dimensions: AssetAnalysisDimension[]; path: AnalysisPathStep[]; filters: AssetAnalysisFilters; rows: AnalysisRow[]; total: number; summary: AnalysisTotals; isLeaf: boolean; breadcrumbs: Array; } export const ANALYSIS_META: Record = { assets: { title: '资产结构', description: '从车型看在库、租赁与自营结构,再定位区域和城市。', metric: 'assets', unit: '台' }, operating: { title: '运营分析', description: '按区域、城市、车型和运营状态逐层比较运营分布。', metric: 'operating', unit: '台' }, inventory: { title: '库存分析', description: '按区域、城市和车型定位库存。', metric: 'inventory', unit: '台' }, abnormal: { title: '异动分析', description: '交叉分析异动类型、区域与已异动时长,识别集中与积压。', metric: 'abnormal', unit: '台' }, pending: { title: '待交车分析', description: '按大区、城市和车型查看待交车分布。', metric: 'pending', unit: '台' }, department: { title: '部门分析', description: '从部门到业务负责人和车型,查看运营资产配置。', metric: 'operating', unit: '台' }, customer: { title: '客户分析', description: '从客户到车型和区域,查看运营资产集中度。', metric: 'operating', unit: '台' }, }; export const DIMENSION_LABELS: Record = { type: '车型', model: '型号', region: '区域', city: '城市', assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态', inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长', department: '部门', manager: '业务负责人', customer: '客户', }; const VALUE_LABELS: Partial>> = { assetStatus: { operating: '运营中', inventory: '在库', other: '其他状态' }, inventoryState: { abnormal: '异动', pending: '待交车', available: '可运营', awaiting: '待运营', unclassified: '未分类库存', 'not-applicable': '不适用', }, abnormalAge: { '0-3d': '不足 3 天', '3-7d': '3–7 天', '7-15d': '7–15 天', '15-30d': '15–30 天', '30d+': '30 天及以上', unknown: '审批时间缺失', }, }; const DIMENSION_ORDER: Partial> = { region: ['华东', '华南', '华北', '华中', '西南', '西北', '其他'], abnormalAge: ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown'], assetStatus: ['inventory', 'operating', 'other'], assetDisposition: ['在库', '租赁', '自营', '其他运营', '其他'], inventoryState: ['available', 'awaiting', 'pending', 'abnormal', 'unclassified', 'not-applicable'], }; const PATHS: Record, readonly AssetAnalysisDimension[]> = { assets: ['type', 'assetDisposition', 'region', 'city'], operating: ['region', 'city', 'type', 'operationKind'], inventory: ['region', 'city', 'type', 'inventoryState'], pending: ['region', 'city', 'type'], department: ['department', 'manager', 'type'], customer: ['customer', 'type', 'region'], }; export function getAnalysisDimensions( view: AnalysisView, abnormalStart: AbnormalStartDimension = 'abnormalType', ): AssetAnalysisDimension[] { if (view !== 'abnormal') return [...PATHS[view]]; const comparisons: AbnormalStartDimension[] = ['abnormalType', 'region', 'abnormalAge']; return [abnormalStart, ...comparisons.filter(dimension => dimension !== abnormalStart), 'type']; } export function getDimensionValueLabel(dimension: AssetAnalysisDimension, value: string): string { return VALUE_LABELS[dimension]?.[value] ?? (value || '未标注'); } /** Counts derive from the same mutually exclusive state dimensions as the server. */ export function cellMatchesMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean { switch (metric) { case 'assets': return true; case 'operating': return cell.assetStatus === 'operating'; case 'inventory': return cell.assetStatus === 'inventory'; case 'abnormal': return cell.assetStatus === 'inventory' && cell.inventoryState === 'abnormal'; case 'pending': return cell.assetStatus === 'inventory' && cell.inventoryState === 'pending'; } } export function filterAnalysisCells( cells: readonly AssetAnalysisCell[], filters: AssetAnalysisFilters = {}, metric: AssetAnalysisMetric = 'assets', ): AssetAnalysisCell[] { const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== '') as Array<[AssetAnalysisDimension, string]>; return cells.filter(cell => cellMatchesMetric(cell, metric) && entries.every(([dimension, value]) => cell[dimension] === value)); } const percent = (count: number, total: number): number => total > 0 ? count / total * 100 : 0; export function summarizeAnalyticsCells(cells: readonly AssetAnalysisCell[]): AnalysisTotals { const totals: AnalysisTotals = { assets: 0, operating: 0, inventory: 0, abnormal: 0, pending: 0, other: 0, operatingRate: 0, inventoryRate: 0, abnormalRate: 0, pendingRate: 0, }; for (const cell of cells) { totals.assets += cell.quantity; if (cell.assetStatus === 'operating') totals.operating += cell.quantity; else if (cell.assetStatus === 'inventory') { totals.inventory += cell.quantity; if (cell.inventoryState === 'abnormal') totals.abnormal += cell.quantity; if (cell.inventoryState === 'pending') totals.pending += cell.quantity; } else totals.other += cell.quantity; } totals.operatingRate = percent(totals.operating, totals.assets); totals.inventoryRate = percent(totals.inventory, totals.assets); totals.abnormalRate = percent(totals.abnormal, totals.inventory); totals.pendingRate = percent(totals.pending, totals.inventory); return totals; } export function aggregateAnalysisRows( cells: readonly AssetAnalysisCell[], dimension: AssetAnalysisDimension, metric: AssetAnalysisMetric = 'assets', sort: AnalysisSort = 'count-desc', ): AnalysisAggregateRow[] { const groups = new Map(); for (const cell of cells) { const value = cell[dimension]; const group = groups.get(value); if (group) group.push(cell); else groups.set(value, [cell]); } const total = summarizeAnalyticsCells(cells)[metric]; const rows: AnalysisAggregateRow[] = []; for (const [value, group] of groups) { const totals = summarizeAnalyticsCells(group); const count = totals[metric]; if (count > 0) rows.push({ value, label: getDimensionValueLabel(dimension, value), count, share: percent(count, total), totals }); } const compareLabels = (a: AnalysisAggregateRow, b: AnalysisAggregateRow) => { const order = DIMENSION_ORDER[dimension]; if (order) { const aIndex = order.indexOf(a.value); const bIndex = order.indexOf(b.value); const result = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex); if (result !== 0) return result; } return a.label.localeCompare(b.label, 'zh-CN', { numeric: true }); }; return rows.sort((a, b) => { const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0; return difference || compareLabels(a, b); }); } /** Keep global filters and drill selections intersected; a stale path cannot widen scope. */ export function getAnalysisSlice(cells: readonly AssetAnalysisCell[], options: AnalysisOptions): AnalysisSlice { const { view, abnormalStart = 'abnormalType', sort = 'count-desc' } = options; const metric = ANALYSIS_META[view].metric; const filters: AssetAnalysisFilters = {}; for (const [key, value] of Object.entries(options.filters ?? {})) { if (value !== undefined && value !== '') filters[key as AssetAnalysisDimension] = value; } const dimensions = getAnalysisDimensions(view, abnormalStart).filter(dimension => filters[dimension] === undefined); const path: AnalysisPathStep[] = []; let conflictingPath = false; for (const step of options.path ?? []) { const globalValue = filters[step.dimension]; if (globalValue !== undefined) { if (globalValue !== step.value) conflictingPath = true; continue; } // Only a prefix of this analysis schema is valid. Unrelated old paths are discarded. if (dimensions[path.length] !== step.dimension) break; path.push({ dimension: step.dimension, value: step.value }); } for (const step of path) filters[step.dimension] = step.value; const scopedCells = conflictingPath ? [] : filterAnalysisCells(cells, filters); const summary = summarizeAnalyticsCells(scopedCells); const dimension = dimensions[path.length] ?? null; const isLeaf = dimension === null; const rows: AnalysisRow[] = dimension === null ? [] : aggregateAnalysisRows(scopedCells, dimension, metric, sort).map(row => ({ ...row, nextPath: [...path, { dimension, value: row.value }], isLeaf: path.length + 1 === dimensions.length, })); return { metric, dimension, dimensions, path, filters, rows, total: summary[metric], summary, isLeaf, breadcrumbs: path.map(step => ({ ...step, label: getDimensionValueLabel(step.dimension, step.value) })), }; } export type AnalysisFilterValues = Partial>; export interface CustomAnalysisRow { key: string; values: Partial>; label: string; count: number; share: number; totals: AnalysisTotals; } export interface CustomAnalysisOptions { view: AnalysisView; dimensions: readonly AssetAnalysisDimension[]; filterValues?: AnalysisFilterValues; sort?: AnalysisSort; } export interface CustomAnalysis { rows: CustomAnalysisRow[]; total: number; summary: AnalysisTotals; dimensions: AssetAnalysisDimension[]; metric: AssetAnalysisMetric; } const COMMON_DIMENSIONS: readonly AssetAnalysisDimension[] = [ 'type', 'model', 'region', 'city', 'department', 'manager', 'customer', ]; const BUSINESS_DIMENSIONS: Record = { assets: ['assetDisposition', 'operationKind'], operating: ['operationKind'], inventory: ['operationKind'], abnormal: ['abnormalType', 'abnormalAge'], pending: [], department: ['operationKind'], customer: ['operationKind'], }; /** Dimensions are choices, not a required drill sequence. */ export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] { const common = view === 'inventory' ? COMMON_DIMENSIONS.filter(dimension => dimension !== 'manager' && dimension !== 'customer') : COMMON_DIMENSIONS; return [...common, ...BUSINESS_DIMENSIONS[view]]; } export function getDefaultDimensions(view: AnalysisView): AssetAnalysisDimension[] { if (view === 'assets') return ['type']; if (view === 'abnormal') return ['abnormalType']; if (view === 'department') return ['department']; if (view === 'customer') return ['customer']; return ['region']; } /** Multiple values of one dimension are OR; selections across dimensions are AND. */ export function filterCustomCells( cells: readonly AssetAnalysisCell[], filterValues: AnalysisFilterValues = {}, metric: AssetAnalysisMetric = 'assets', ): AssetAnalysisCell[] { const constraints: Array<[AssetAnalysisDimension, Set]> = []; for (const dimension of ASSET_ANALYSIS_DIMENSIONS) { const selected = filterValues[dimension]; if (!Array.isArray(selected) || selected.length === 0) continue; constraints.push([dimension, new Set(selected)]); } return cells.filter(cell => cellMatchesMetric(cell, metric) && constraints.every(([dimension, selected]) => selected.has(cell[dimension]))); } export function compareCustomLabels(a: CustomAnalysisRow, b: CustomAnalysisRow, dimensions: readonly AssetAnalysisDimension[]): number { for (const dimension of dimensions) { const aValue = a.values[dimension]!; const bValue = b.values[dimension]!; const order = DIMENSION_ORDER[dimension]; if (order) { const aIndex = order.indexOf(aValue); const bIndex = order.indexOf(bValue); const difference = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex); if (difference !== 0) return difference; } const difference = getDimensionValueLabel(dimension, aValue).localeCompare(getDimensionValueLabel(dimension, bValue), 'zh-CN', { numeric: true }); if (difference !== 0) return difference; } return a.key.localeCompare(b.key); } export function getCustomAnalysis(cells: readonly AssetAnalysisCell[], options: CustomAnalysisOptions): CustomAnalysis { const metric = ANALYSIS_META[options.view].metric; const available = new Set(getAvailableDimensions(options.view)); const dimensions = [...new Set(options.dimensions.filter(dimension => available.has(dimension)))]; const scopedCells = filterCustomCells(cells, options.filterValues); const summary = summarizeAnalyticsCells(scopedCells); const total = summary[metric]; const groups = new Map(); for (const cell of scopedCells) { // Structured JSON preserves values containing separators or quotes. const entries = dimensions.map(dimension => [dimension, cell[dimension]] as const); const key = JSON.stringify(entries); const group = groups.get(key); if (group) group.cells.push(cell); else groups.set(key, { values: Object.fromEntries(entries), cells: [cell] }); } const rows: CustomAnalysisRow[] = []; for (const [key, group] of groups) { const totals = summarizeAnalyticsCells(group.cells); const count = totals[metric]; if (count <= 0) continue; const label = dimensions.length === 0 ? '合计' : dimensions.map(dimension => getDimensionValueLabel(dimension, group.values[dimension]!)).join(' · '); rows.push({ key, values: group.values, label, count, share: percent(count, total), totals }); } const sort = options.sort ?? 'count-desc'; rows.sort((a, b) => { const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0; return difference || compareCustomLabels(a, b, dimensions); }); return { rows, total, summary, dimensions, metric }; }