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.
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
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 };
|
||
}
|