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
+4
View File
@@ -1,5 +1,9 @@
/** Current or most recent effective movement associated with a vehicle. */
export interface AbnormalMoveDetail {
/** Final PASS task completion time of the movement approval workflow. */
approvalPassedAt?: string | null;
/** Elapsed days at the analysis snapshot time; null when approval time is unknown. */
elapsedDays?: number | null;
id: string;
type: string;
work: string | null;
+71
View File
@@ -0,0 +1,71 @@
/** Aggregated management analysis; dimensions are shared by aggregates and final details. */
export const ASSET_ANALYSIS_DIMENSIONS = [
'type', 'model', 'region', 'city', 'assetStatus', 'assetDisposition', 'operationKind',
'inventoryState', 'abnormalType', 'abnormalAge', 'department', 'manager', 'customer',
] as const;
export type AssetAnalysisDimension = typeof ASSET_ANALYSIS_DIMENSIONS[number];
export type AssetAnalysisMetric = 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending';
export type AssetAnalysisFilters = Partial<Record<AssetAnalysisDimension, string>>;
/** Values within a dimension are ORed; dimensions and the entry scope are ANDed. */
export type AssetAnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
export type AbnormalAgeBucket = '0-3d' | '3-7d' | '7-15d' | '15-30d' | '30d+' | 'unknown';
export interface AssetAnalysisCell extends Record<AssetAnalysisDimension, string> {
assetStatus: 'operating' | 'inventory' | 'other';
assetDisposition: '在库' | '租赁' | '自营' | '其他运营' | '其他';
inventoryState: 'abnormal' | 'pending' | 'available' | 'awaiting' | 'unclassified' | 'not-applicable';
abnormalAge: AbnormalAgeBucket;
quantity: number;
}
export interface AssetAnalysisSummary {
totalAssets: number;
operating: { total: number; self: number; leased: number; public: number; hanging: number };
inventory: { total: number; inStock: number; abnormal: number };
pendingDelivery: number;
otherAssets: number;
}
export interface AssetAnalysisResponse {
generatedAt: string;
summary: AssetAnalysisSummary;
cells: AssetAnalysisCell[];
/** True only when a verified approval time source is available. */
approvalTimeAvailable: boolean;
}
export interface AssetAnalysisDetailRequest {
metric: AssetAnalysisMetric;
filters: AssetAnalysisFilters;
filterValues?: AssetAnalysisFilterValues;
/** Use the aggregate's reference time to keep duration bucket boundaries consistent. */
generatedAt?: string;
scope?: AssetAnalysisScope;
inventoryKeys?: string[];
}
/** Carries the existing dashboard entry's exact list scope into every drill level. */
export interface AssetAnalysisScope {
inventoryKeys?: string[];
brand?: string;
batch?: string;
model?: string;
location?: string;
status?: string;
category?: string;
vehicleType?: string;
manager?: string;
customer?: string;
department?: string;
attendance?: string;
isColdChain?: string;
isTrailer?: string;
source?: string;
subject?: string | null;
}
export interface AssetAnalysisRequest {
scope?: AssetAnalysisScope;
inventoryKeys?: string[];
}
+7
View File
@@ -34,3 +34,10 @@ export function canAccessEnergy(roles: readonly string[] | null | undefined): bo
if (!roles || roles.length === 0) return false;
return roles.some(r => ENERGY_ACCESS_ROLES.includes(r) || r === ENERGY_FULL_ACCESS);
}
/** Shared by normal login and permission preview; full access wins over department. */
export function resolvePermissionLevel(roles: readonly string[]): 'full' | 'department' | 'personal' {
if (roles.some(role => FULL_ACCESS_ROLES.includes(role))) return 'full';
if (roles.some(role => DEPT_ACCESS_ROLES.includes(role))) return 'department';
return 'personal';
}