feat(assets): 完善故障风险多维分析与管理总览
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-10-16 12:38:52 +08:00
parent 4dec824285
commit f107e96d3c
17 changed files with 592 additions and 82 deletions
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { mapFault } from './model.js';
import { faultDashboard, dashboardAnalysis, filterDashboardRows } from './dashboard.js';
import { createFaultRouter } from './index.js';
import type { AuthUser } from '../../auth/types.js';
const updatedAt='2026-10-13T08:00:00Z';
const q={start:'2026-10-12',end:'2026-10-13'};
const fault=(id:string,extra:Record<string,unknown>={})=>mapFault({id,fault_code:`F${id}`,matched_vehicle_id:'v1',plate_number:'沪A001',model_name:'车型A',province_name:'上海市',customer_name:'客户A',business_dept:'部门A',reported_at:'2026-10-13 08:00:00',handling_status:'待处理',fault_level:1,fault_categories:'底盘故障,三电故障',...extra},new Map());
const rows=[fault('1'),fault('2',{handling_status:'已归档',archived_at:'2026-10-13 12:00:00'}),fault('3',{reported_at:'2026-10-01 08:00:00',handling_status:'处理中',deadline_at:'2026-10-12 15:00:00',matched_vehicle_id:'v2'}),fault('4',{matched_vehicle_id:null,plate_number:null,fault_level:null,fault_categories:null,customer_name:null}),fault('5',{reported_at:'2026-10-10 08:00:00',handling_status:'挂起'})];
test('dashboard separates reporting cohort from all-date current workload, preserves unknowns and deduplicates vehicles',()=>{
const d=faultDashboard(rows,q,updatedAt);
assert.equal(d.totals.reported,3);assert.equal(d.totals.vehicles,1);assert.equal(d.totals.customers,1);
assert.equal(d.totals.open,3);assert.equal(d.totals.waiting+d.totals.processing,d.totals.open);assert.equal(d.totals.overdue,1);assert.equal(d.totals.longestOverdueDays,2);
assert.equal(d.totals.previous,1);assert.equal(d.totals.change,200);
assert.equal(d.quality.missingLevel,1);assert.equal(d.quality.missingVehicle,1);assert.equal(d.quality.missingCategory,1);
assert.equal(d.focus[0].id,'3');assert.equal(d.distributions.region.reduce((n,r)=>n+r.reported,0),3);
const m=rows[0].assetCell.model;assert.equal(d.matrix.totals[m],3);assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='底盘故障')?.count,2);
assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='三电故障')?.count,2);
});
test('dashboard drill scope and all filters reconcile to visible aggregates without leaking internal fields',()=>{
const d=faultDashboard(rows,q,updatedAt);const group=d.distributions.customer.find(r=>r.label!=='未关联客户')!;
const details=dashboardAnalysis(rows,{...q,customer:group.value},updatedAt);
assert.equal(details.rows.length,group.reported);assert.ok(details.rows.every(r=>!('vehicleKey'in r)&&!('managerId'in r)));
assert.deepEqual(dashboardAnalysis(rows,{...q,scope:'overdue'},updatedAt).rows.map(r=>r.id),['3']);
assert.equal(dashboardAnalysis(rows,{...q,scope:'ledger',id:'5'},updatedAt).rows.length,1);
assert.equal(filterDashboardRows(rows,{level:'未标注',category:'未标注'}).length,1);
assert.equal(dashboardAnalysis(rows,{...q,scope:'cohort',level:'未标注'},updatedAt).rows.length,1);
const empty=faultDashboard(rows,{...q,region:'不存在'},updatedAt);assert.equal(empty.totals.reported,0);assert.equal(empty.totals.change,null);assert.equal(empty.totals.longestOverdueDays,0);
});
test('dashboard and report endpoints enforce authentication, permissions, dates and shared filters',async()=>{
let loads=0;const router=createFaultRouter(async()=>{loads++;return{rows,updatedAt};});const anonymous=new Hono();anonymous.route('/f',router);
assert.equal((await anonymous.request('/f/dashboard')).status,401);assert.equal(loads,0);
const user:AuthUser={userId:'test',loginName:'test',userName:'测试',roles:[],permissionLevel:'department',depName:'部门A',depCode:'a'};
const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user);await next();});app.route('/f',router);
assert.equal((await app.request('/f/dashboard?start=bad')).status,400);assert.equal(loads,0);
assert.equal((await app.request('/f/dashboard-analysis?scope=unknown')).status,400);assert.equal(loads,0);
const report=await(await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json();assert.equal(report.totals.reported,2);assert.equal(report.options.level.some((o:{value:string})=>o.value==='未标注'),false);
const trend=await(await app.request('/f/trend?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(trend.totals.reported,0);
const weekly=await(await app.request('/f/weekly-report?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(weekly.totals.reported,0);
});
@@ -0,0 +1,67 @@
import { FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js';
import { faultAnalysis, faultOverview, isOverdueFault, type ScopedFault } from './model.js';
import { parseFaultTrendQuery, selectFaultCohort } from './trend.js';
export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension): string[] {
if (key === 'handler') return row.handlers.map(h => h.value);
if (key === 'level') return [row.level || '未标注'];
if (key === 'category') return row.categories?.length ? row.categories : ['未标注'];
if (key === 'inventory') return [row.inventory];
return [row.assetCell[key]];
}
function label(row: ScopedFault, key: FaultDashboardDimension, value: string) {
return key === 'customer' ? row.customerLabel : key === 'handler' ? row.handlers.find(h => h.value === value)?.label || value : value;
}
export function filterDashboardRows(rows: ScopedFault[], params: Record<string, string>) {
return rows.filter(row => (Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).every(key => !params[key] || dashboardValues(row, key).includes(params[key])));
}
export function parseDashboardQuery(params: Record<string, string>) {
const query = parseFaultTrendQuery({ start: params.start, end: params.end });
const scope = params.scope || 'cohort';
if (!['cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围');
return { ...query, scope: scope as FaultDashboardScope };
}
export function dashboardAnalysis(rows: ScopedFault[], params: Record<string, string>, updatedAt: string) {
const query = parseDashboardQuery(params);
let selected = filterDashboardRows(rows, params);
if (params.id) selected = selected.filter(row => row.id === params.id);
if (query.scope === 'cohort') selected = selectFaultCohort(selected, query);
return faultAnalysis(selected, query.scope === 'cohort' ? 'ledger' : query.scope, updatedAt);
}
export function faultDashboard(visible: ScopedFault[], params: Record<string, string>, updatedAt: string): FaultDashboardResponse {
const query = parseDashboardQuery(params);
const all = filterDashboardRows(visible, params);
const cohort = selectFaultCohort(all, query);
const overview = faultOverview(all, updatedAt).totals;
const open = all.filter(r => ['待处理', '处理中'].includes(r.status));
const overdue = open.filter(r => isOverdueFault(r, updatedAt));
const vehicles = (rows: ScopedFault[]) => new Set(rows.map(r => r.vehicleKey).filter(Boolean)).size;
const options = Object.fromEntries((Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).map(key => [key,
[...new Map(visible.flatMap(row => dashboardValues(row, key).map(value => [value, { value, label: label(row, key, value) }] as const))).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')),
])) as FaultDashboardResponse['options'];
function groups(key: 'region' | 'level' | 'customer' | 'model') {
const buckets = new Map<string, ScopedFault[]>();
cohort.forEach(row => { const value = dashboardValues(row, key)[0]; const group = buckets.get(value) || []; group.push(row); buckets.set(value, group); });
return [...buckets].map(([value, rows]) => ({ value, label: label(rows[0], key, value), reported: rows.length,
closed: rows.filter(r => r.status === '已归档').length, unclosed: rows.filter(r => r.status !== '已归档').length,
vehicles: vehicles(rows), open: open.filter(r => dashboardValues(r, key).includes(value)).length,
})).sort((a, b) => b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN'));
}
const distributions = { region: groups('region'), level: groups('level'), customer: groups('customer'), model: groups('model') };
const categoryKeys = [...new Set(cohort.flatMap(row => dashboardValues(row, 'category')))].sort((a,b)=>a.localeCompare(b,'zh-CN'));
const matrix = { models: distributions.model.map(r => ({value:r.value,label:r.label})), categories: categoryKeys.map(value => ({value,label:value})),
cells: distributions.model.flatMap(model => categoryKeys.map(category => ({model:model.value,category,count:cohort.filter(r=>r.assetCell.model===model.value&&dashboardValues(r,'category').includes(category)).length}))),
totals: Object.fromEntries(distributions.model.map(r=>[r.value,r.reported])),
};
const duration = Date.parse(query.end) - Date.parse(query.start) + 86400000;
const previousStart = new Date(Date.parse(query.start) - duration).toISOString().slice(0,10);
const previous = all.filter(r => r.reportedAt && r.reportedAt.slice(0,10) >= previousStart && r.reportedAt.slice(0,10) < query.start).length;
const focus = faultAnalysis([...open].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows;
return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus,
totals: { reported:cohort.length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size,
previous,change:previous ? (cohort.length-previous)/previous*100 : null,open:overview.open,waiting:overview.waiting,processing:overview.processing,overdue:overview.overdue,
longestOverdueDays:Math.max(0,...overdue.map(r=>Math.ceil((Date.parse(updatedAt)-Date.parse(`${r.deadlineAt!.replace(' ','T')}+08:00`))/86400000))),
todayReported:overview.reported,todayClosed:overview.closed,ledger:all.length },
quality:{missingLevel:cohort.filter(r=>!r.level||r.level==='未标注').length,missingCategory:cohort.filter(r=>!r.categories?.length).length,missingVehicle:cohort.filter(r=>!r.vehicleKey).length,missingDeadline:open.filter(r=>!r.deadlineAt).length,missingReport:all.filter(r=>!r.reportedAt).length},
};
}
+15 -2
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import { dashboardAnalysis, faultDashboard, filterDashboardRows, parseDashboardQuery } from './dashboard.js';
import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js';
import type { AuthUser } from '../../auth/types.js';
import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js';
@@ -8,6 +9,18 @@ import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js
export function createFaultRouter(load = loadFaults) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
for (const path of ['/dashboard', '/dashboard-analysis'] as const) {
app.get(path, async c => {
const user = c.get('user');
if (!user) return c.json({ error: 'Unauthorized' }, 401);
const params = c.req.query();
try { parseDashboardQuery(params); } catch(error) { return c.json({error:(error as Error).message},400); }
try {
const data = await load(); const rows = visibleFaults(data.rows,user); c.header('Cache-Control','no-store');
return c.json(path === '/dashboard' ? faultDashboard(rows,params,data.updatedAt) : dashboardAnalysis(rows,params,data.updatedAt));
} catch { return c.json({error:'故障分析暂时不可用,请稍后重试'},503); }
});
}
for (const path of ['/trend', '/trend-analysis'] as const) {
app.get(path, async c => {
const user = c.get('user');
@@ -17,7 +30,7 @@ export function createFaultRouter(load = loadFaults) {
catch (error) { return c.json({ error: (error as Error).message }, 400); }
try {
const data = await load();
const rows = visibleFaults(data.rows, user);
const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store');
return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt));
} catch (error) {
@@ -35,7 +48,7 @@ export function createFaultRouter(load = loadFaults) {
catch (error) { return c.json({ error: (error as Error).message }, 400); }
try {
const data = await load();
const rows = visibleFaults(data.rows, user);
const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query());
c.header('Cache-Control', 'no-store');
return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt));
} catch (error) {
+7 -2
View File
@@ -9,6 +9,7 @@ export type RawFault = Record<string, unknown>;
export interface ScopedFault extends FaultRecord {
subject: string; departmentName: string; managerId: string; missingVehicle: boolean;
assetCell: AssetAnalysisCell;
vehicleKey?: string;
}
export function normalizeFaultStatus(value: unknown): string {
const status = String(value ?? '').trim();
@@ -77,6 +78,10 @@ export function mapFault(row: RawFault, directory: Map<string, string>): ScopedF
manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1,
};
return {
vehicleKey: text(row.matched_vehicle_id) || (text(row.plate_number) ? `plate:${text(row.plate_number)}` : ''),
level: text(row.level_label) || ({ '1': 'L1-特急', '2': 'L2-紧急', '3': 'L3-一般', '4': 'L4-提示' } as Record<string, string>)[text(row.fault_level)] || '未标注',
categories: [...new Set(text(row.fault_categories).split(/[,,、;;]/).map(v => v.trim()).filter(Boolean))],
progress: text(row.solve_process),
id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆',
description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null,
city, status: normalizeFaultStatus(row.handling_status), assetCell,
@@ -148,7 +153,7 @@ export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updated
quality: { missingReport: rows.filter(r => !r.reportedAt).length,
invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length,
missingVehicle: rows.filter(r => r.missingVehicle).length },
details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => row), total: details.length, page, pageSize },
details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => row), total: details.length, page, pageSize },
};
}
@@ -187,5 +192,5 @@ export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shar
const date = faultBusinessDate(updatedAt);
const selected = selectFaultMetric(rows, metric, date, updatedAt);
return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])),
rows: selected.map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
rows: selected.map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
}
@@ -4,6 +4,7 @@ import { mapFault, type RawFault } from './model.js';
// OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause.
export const FAULT_SQL = `SELECT
f.fault_level, f.fault_categories, f.solve_process,
CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status,
DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at,
DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at,