feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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<AnalysisPathStep & { label: string }>;
|
||||
}
|
||||
|
||||
export const ANALYSIS_META: Record<AnalysisView, {
|
||||
title: string;
|
||||
description: string;
|
||||
metric: AssetAnalysisMetric;
|
||||
unit: '台';
|
||||
}> = {
|
||||
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<AssetAnalysisDimension, string> = {
|
||||
type: '车型', model: '型号', region: '区域', city: '城市',
|
||||
assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态',
|
||||
inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长',
|
||||
department: '部门', manager: '业务负责人', customer: '客户',
|
||||
};
|
||||
|
||||
const VALUE_LABELS: Partial<Record<AssetAnalysisDimension, Record<string, string>>> = {
|
||||
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<Record<AssetAnalysisDimension, readonly string[]>> = {
|
||||
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<Exclude<AnalysisView, 'abnormal'>, 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<string, AssetAnalysisCell[]>();
|
||||
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<Record<AssetAnalysisDimension, string[]>>;
|
||||
|
||||
export interface CustomAnalysisRow {
|
||||
key: string;
|
||||
values: Partial<Record<AssetAnalysisDimension, string>>;
|
||||
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<AnalysisView, readonly AssetAnalysisDimension[]> = {
|
||||
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<string>]> = [];
|
||||
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<string, { values: CustomAnalysisRow['values']; cells: AssetAnalysisCell[] }>();
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user