feat(assets): add dimension analysis and authorized user previews
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:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
+365
View File
@@ -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 };
}