Integrate fault management from fault_1013_improve at 1674f05, including overview, trend and weekly reports, multidimensional analysis, authorized fault details, L1 follow-up, source breakdown and Excel exports. Adapt database imports, cross-layer tests and workbook helpers to the main architecture; retain unrelated main functionality.
Validation: TypeScript check, 502 tests, production build, desktop/mobile interactions and six-sheet Excel export passed.
This commit is contained in:
@@ -13,6 +13,7 @@ import mileageRouter from './routes/mileage/index.js';
|
||||
import schedulingRouter from './routes/scheduling/index.js';
|
||||
import vehicleHeatmapRouter from './routes/vehicle-heatmap/routes.js';
|
||||
import vehiclesRouter from './routes/vehicles/routes.js';
|
||||
import assetFaultRouter from './routes/asset-faults/index.js';
|
||||
import assetAssistantRouter from './routes/asset-assistant/index.js';
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export function createApp(): Hono {
|
||||
app.use('/api/*', createPreviewMiddleware());
|
||||
|
||||
app.route('/api/vehicles', vehiclesRouter);
|
||||
app.route('/api/asset-faults', assetFaultRouter);
|
||||
app.route('/api/asset-assistant', assetAssistantRouter);
|
||||
app.route('/api/mileage', mileageRouter);
|
||||
app.route('/api/scheduling', schedulingRouter);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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);
|
||||
});
|
||||
|
||||
test('focus contains only L1 unclosed faults across all dates and keeps totals and full drill consistent', () => {
|
||||
const focusRows = [
|
||||
fault('waiting', { reported_at: '2026-09-01 08:00:00' }),
|
||||
fault('processing', { handling_status: '处理中', deadline_at: '2026-10-12 15:00:00' }),
|
||||
fault('paused', { handling_status: '挂起', deadline_at: '2026-10-01 08:00:00' }),
|
||||
fault('review', { handling_status: '审核中' }),
|
||||
fault('unknown', { handling_status: '其他状态' }),
|
||||
fault('sixth'),
|
||||
fault('seventh'),
|
||||
fault('closed', { handling_status: '已归档' }),
|
||||
fault('l2-overdue', { fault_level: 2, deadline_at: '2026-10-01 08:00:00' }),
|
||||
fault('l3', { fault_level: 3 }),
|
||||
fault('l4', { fault_level: 4 }),
|
||||
fault('unmarked', { fault_level: null }),
|
||||
];
|
||||
const dashboard = faultDashboard(focusRows, q, updatedAt);
|
||||
assert.deepEqual(dashboard.focusTotals, { total: 7, overdue: 1 });
|
||||
assert.equal(dashboard.focus.length, 5);
|
||||
assert.equal(dashboard.focus[0].id, 'processing');
|
||||
assert.ok(dashboard.focus.every(row => row.level === 'L1-特急' && row.status !== '已归档'));
|
||||
const allFocus = dashboardAnalysis(focusRows, { ...q, scope: 'focus' }, updatedAt).rows;
|
||||
assert.equal(allFocus.length, dashboard.focusTotals.total);
|
||||
assert.ok(allFocus.some(row => row.id === 'waiting'));
|
||||
assert.ok(allFocus.some(row => row.id === 'paused'));
|
||||
assert.ok(allFocus.some(row => row.id === 'review'));
|
||||
assert.ok(allFocus.every(row => !('vehicleKey' in row) && !('managerId' in row)));
|
||||
assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', id: 'closed' }, updatedAt).rows, []);
|
||||
const filtered = faultDashboard(focusRows, { ...q, level: 'L2-紧急' }, updatedAt);
|
||||
assert.deepEqual(filtered.focus, []);
|
||||
assert.deepEqual(filtered.focusTotals, { total: 0, overdue: 0 });
|
||||
assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', level: 'L2-紧急' }, updatedAt).rows, []);
|
||||
});
|
||||
test('focus dashboard and full drill enforce permissions and business filters before selecting L1 faults', async () => {
|
||||
const scopedRows = [fault('allowed'), fault('outside', { business_dept: '部门B' }), fault('closed', { handling_status: '已归档' }), fault('lower', { fault_level: 2 })];
|
||||
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', createFaultRouter(async () => ({ rows: scopedRows, updatedAt })));
|
||||
const report = await (await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json();
|
||||
assert.deepEqual(report.focus.map((row: { id: string }) => row.id), ['allowed']);
|
||||
assert.deepEqual(report.focusTotals, { total: 1, overdue: 0 });
|
||||
const response = await app.request('/f/dashboard-analysis?start=2026-10-12&end=2026-10-13&scope=focus');
|
||||
assert.equal(response.status, 200);
|
||||
const detail = await response.json();
|
||||
assert.deepEqual(detail.rows.map((row: { id: string }) => row.id), ['allowed']);
|
||||
const empty = await (await app.request('/f/dashboard-analysis?scope=focus&department=不存在')).json();
|
||||
assert.deepEqual(empty.rows, []);
|
||||
});
|
||||
|
||||
test('report source follows explicit OneOS report method before legacy task linkage', () => {
|
||||
const cases: [Record<string, unknown>, 'operations' | 'customer'][] = [
|
||||
[{ report_method: '运维上报', source_work_order_id: '9007199254740993' }, 'operations'],
|
||||
[{ report_method: '客户上报', source_work_order_id: null }, 'customer'],
|
||||
[{ report_method: ' 客户上报 ', source_work_order_id: '123' }, 'customer'],
|
||||
[{ report_method: null, source_work_order_id: '9007199254740993' }, 'customer'],
|
||||
[{ report_method: '', source_work_order_id: null }, 'operations'],
|
||||
[{ report_method: null, source_work_order_id: null, fault_source: 6 }, 'operations'],
|
||||
];
|
||||
for (const [raw, expected] of cases) assert.equal(fault('source', raw).reportMethod, expected);
|
||||
});
|
||||
test('reported source counts reconcile with cohort, filtered drills, permissions and export labels', async () => {
|
||||
const sourceRows = [
|
||||
fault('ops-linked', { report_method: '运维上报', source_work_order_id: '123' }),
|
||||
fault('ai-explicit', { report_method: '客户上报', source_work_order_id: null }),
|
||||
fault('ai-legacy', { report_method: null, source_work_order_id: '9007199254740993', handling_status: '已归档' }),
|
||||
fault('ops-legacy', { report_method: null, source_work_order_id: null }),
|
||||
fault('previous-ai', { report_method: '客户上报', reported_at: '2026-10-10 10:00:00' }),
|
||||
fault('hidden-ai', { report_method: '客户上报', business_dept: '部门B' }),
|
||||
];
|
||||
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', createFaultRouter(async () => ({ rows: sourceRows, updatedAt })));
|
||||
const query = new URLSearchParams(q);
|
||||
const report = await (await app.request(`/f/dashboard?${query}`)).json();
|
||||
assert.equal(report.totals.reported, 4);
|
||||
assert.equal(report.totals.operationsReported, 2);
|
||||
assert.equal(report.totals.customerReported, 2);
|
||||
assert.equal(report.totals.operationsReported + report.totals.customerReported, report.totals.reported);
|
||||
assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'customer').label, 'AI提报');
|
||||
assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'operations').label, '运维提报');
|
||||
for (const [method, expected] of [['operations', ['ops-linked', 'ops-legacy']], ['customer', ['ai-explicit', 'ai-legacy']]] as const) {
|
||||
const response = await app.request(`/f/dashboard-analysis?${query}&scope=cohort&reportMethod=${method}`);
|
||||
assert.equal(response.status, 200);
|
||||
const detail = await response.json();
|
||||
assert.deepEqual(detail.rows.map((row: { id: string }) => row.id).sort(), [...expected].sort());
|
||||
const filtered = await (await app.request(`/f/dashboard?${query}&reportMethod=${method}`)).json();
|
||||
assert.equal(filtered.totals.reported, expected.length);
|
||||
assert.equal(filtered.totals.operationsReported + filtered.totals.customerReported, filtered.totals.reported);
|
||||
}
|
||||
const empty = await (await app.request(`/f/dashboard?${query}®ion=不存在`)).json();
|
||||
assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { FAULT_REPORT_METHODS, 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];
|
||||
if (key === 'reportMethod') return [row.reportMethod || 'operations'];
|
||||
return [row.assetCell[key]];
|
||||
}
|
||||
function label(row: ScopedFault, key: FaultDashboardDimension, value: string) {
|
||||
if (key === 'reportMethod') return FAULT_REPORT_METHODS[value as keyof typeof FAULT_REPORT_METHODS] || value;
|
||||
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 (!['focus', 'cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围');
|
||||
return { ...query, scope: scope as FaultDashboardScope };
|
||||
}
|
||||
function selectFocusFaults(rows: ScopedFault[]) {
|
||||
return rows.filter(row => row.level === 'L1-特急' && row.status !== '已归档');
|
||||
}
|
||||
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);
|
||||
if (query.scope === 'focus') selected = selectFocusFaults(selected);
|
||||
return faultAnalysis(selected, query.scope === 'cohort' || query.scope === 'focus' ? '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 focusRows = selectFocusFaults(all);
|
||||
const focusTotals = { total: focusRows.length, overdue: focusRows.filter(row => isOverdueFault(row, updatedAt)).length };
|
||||
const focus = faultAnalysis([...focusRows].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,focusTotals,
|
||||
totals: { reported:cohort.length,operationsReported:cohort.filter(r=>r.reportMethod!=='customer').length,customerReported:cohort.filter(r=>r.reportMethod==='customer').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},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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';
|
||||
import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js';
|
||||
import { loadFaults } from './repository.js';
|
||||
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');
|
||||
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
||||
let query;
|
||||
try { query = parseFaultTrendQuery(c.req.query()); }
|
||||
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
||||
try {
|
||||
const data = await load();
|
||||
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) {
|
||||
console.error('Fault trend failed:', error instanceof Error ? error.message : 'unknown');
|
||||
return c.json({ error: '故障趋势暂时不可用,请稍后重试' }, 503);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const path of ['/weekly-report', '/weekly-report-analysis'] as const) {
|
||||
app.get(path, async c => {
|
||||
const user = c.get('user');
|
||||
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
||||
let query;
|
||||
try { query = parseFaultWeeklyQuery(c.req.query()); }
|
||||
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
||||
try {
|
||||
const data = await load();
|
||||
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) {
|
||||
console.error('Fault weekly report failed:', error instanceof Error ? error.message : 'unknown');
|
||||
return c.json({ error: '故障运维周报暂时不可用,请稍后重试' }, 503);
|
||||
}
|
||||
});
|
||||
}
|
||||
app.get('/overview', async c => {
|
||||
const user = c.get('user');
|
||||
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
||||
try {
|
||||
const data = await load();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json(faultOverview(visibleFaults(data.rows, user), data.updatedAt));
|
||||
} catch (error) {
|
||||
console.error('Fault overview failed:', error instanceof Error ? error.message : 'unknown');
|
||||
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
|
||||
}
|
||||
});
|
||||
app.get('/analysis', async c => {
|
||||
const user = c.get('user');
|
||||
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
||||
const metric = c.req.query('metric') || '';
|
||||
if (!Object.hasOwn(FAULT_OVERVIEW_METRICS, metric)) return c.json({ error: '无效统计指标' }, 400);
|
||||
try {
|
||||
const data = await load();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json(faultAnalysis(visibleFaults(data.rows, user), metric as FaultOverviewMetric, data.updatedAt));
|
||||
} catch (error) {
|
||||
console.error('Fault analysis failed:', error instanceof Error ? error.message : 'unknown');
|
||||
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
|
||||
}
|
||||
});
|
||||
app.get('/', async c => {
|
||||
const user = c.get('user');
|
||||
if (!user) return c.json({ error: 'Unauthorized' }, 401);
|
||||
let query;
|
||||
try { query = parseFaultQuery(c.req.query()); }
|
||||
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
||||
try {
|
||||
const data = await load();
|
||||
c.header('Cache-Control', 'no-store');
|
||||
return c.json(analyzeFaults(visibleFaults(data.rows, user, query.subject), query, data.updatedAt));
|
||||
} catch (error) {
|
||||
console.error('Fault BI query failed:', error instanceof Error ? error.message : 'unknown');
|
||||
return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503);
|
||||
}
|
||||
});
|
||||
return app;
|
||||
}
|
||||
export default createFaultRouter();
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse, type FaultReportMethod } from '../../../shared/assets/faults.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
import { filterByPermission, maskCustomerName } from '../../auth/permissions.js';
|
||||
import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js';
|
||||
import { deriveModelTag, deriveType, mapMacroRegion } from '../vehicles/model.js';
|
||||
|
||||
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();
|
||||
if (['', '待处理', '待处置', '未处理'].includes(status)) return '待处理';
|
||||
if (['处理中', '处置中', '处理'].includes(status)) return '处理中';
|
||||
if (['挂起', '已挂起'].includes(status)) return '挂起';
|
||||
return status;
|
||||
}
|
||||
export const isOpenFault = (status: string) => ['待处理', '处理中', '挂起'].includes(status);
|
||||
function array(value: unknown, preserveIds = false): unknown[] {
|
||||
try {
|
||||
// BIGINT IDs in legacy longtext arrays must never pass through floating point.
|
||||
const parsed = typeof value === 'string' ? JSON.parse(preserveIds ? value.replace(/([\[,]\s*)(\d+)(?=\s*[,\]])/g, '$1"$2"') : value) : value;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
const text = (value: unknown) => String(value ?? '').trim();
|
||||
const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value));
|
||||
// Match OneOS reportMethod: explicit source wins, task linkage is a legacy fallback.
|
||||
export function faultReportMethod(row: RawFault): FaultReportMethod {
|
||||
const method = text(row.report_method);
|
||||
if (method === '运维上报') return 'operations';
|
||||
if (method === '客户上报') return 'customer';
|
||||
return positiveId(row.source_work_order_id) ? 'customer' : 'operations';
|
||||
}
|
||||
export function faultHandlers(row: RawFault, directory: Map<string, string>): FaultOption[] {
|
||||
const candidates = [
|
||||
[array(row.work_ids, true), array(row.work_names)],
|
||||
[[row.work_id], [row.work_name]],
|
||||
[array(row.handler_ids, true), array(row.handler_names)],
|
||||
[[row.handler_id], [row.handler_name]],
|
||||
];
|
||||
const [ids, names] = candidates.find(([ids, names]) => ids.some(positiveId) || names.some(v => text(v))) ?? [[], []];
|
||||
const handlers = new Map<string, FaultOption>();
|
||||
for (let i = 0; i < Math.max(ids.length, names.length); i++) {
|
||||
const id = positiveId(ids[i]) ? text(ids[i]) : '';
|
||||
const name = text(names[i]);
|
||||
if (!id && !name) continue;
|
||||
const value = id ? `id:${id}` : `name:${name}`;
|
||||
if (!handlers.has(value)) handlers.set(value, { value, label: id ? directory.get(id) || name || `历史处理人(${id})` : name });
|
||||
}
|
||||
return handlers.size ? [...handlers.values()] : [{ value: 'unassigned', label: '未指派' }];
|
||||
}
|
||||
export function faultInventory(operation: unknown, vehicle: unknown, exists: boolean): string {
|
||||
if (!exists || !text(operation)) return '未知';
|
||||
if (!['3', '4', '可运营', '待运营', '在库'].includes(text(operation))) return '不适用';
|
||||
if (text(vehicle) === '14') return '异动';
|
||||
if (text(vehicle) === '4') return '待交车';
|
||||
return ['3', '可运营'].includes(text(operation)) ? '可运营' : ['4', '待运营'].includes(text(operation)) ? '待运营' : '未分类';
|
||||
}
|
||||
export function mapFault(row: RawFault, directory: Map<string, string>): ScopedFault {
|
||||
const customer = text(row.customer_name);
|
||||
const city = text(row.city_name) || text(row.operation_city) || '未知';
|
||||
const rawModel = text(row.model_name);
|
||||
const brands: Record<string, string> = { hyundai: /帕力安|冷链|双飞翼/.test(rawModel) ? '帕力安牌' : '现代', yuejin: '跃进', feichi: '飞驰', sulong: '苏龙', higer: '海格', dongfeng: '东风', yutong: '宇通', chufeng: '楚风', tonghua: '通华', maxus: '大通', mingwei: '明威', wanfeng: '万风', shujie: '舒捷', denza: '腾势', hongyan: '红岩', 'yuanchang brand': '远程牌', others: '其他' };
|
||||
const ownership = ({ '0': '自有', '1': '挂靠', '2': '外租', '3': '自有' } as Record<string, string>)[text(row.vehicle_source)] || text(row.actual_ownership);
|
||||
const model = deriveModelTag(brands[text(row.brand)] || text(row.brand), rawModel, text(row.body_color), ownership, text(row.rental_company));
|
||||
const type = deriveType(rawModel, text(row.model_type));
|
||||
const operation = text(row.operation_status);
|
||||
const operating = ['1', '2', '租赁', '自营'].includes(operation);
|
||||
const inventory = ['3', '4', '可运营', '待运营', '在库'].includes(operation);
|
||||
const unlinkedInventory = !customer && !!row.matched_vehicle_id && inventory;
|
||||
const customerKey = customer ? createHash('sha256').update(customer).digest('hex') : unlinkedInventory ? 'inventory-unlinked' : 'unlinked';
|
||||
const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营', '5': '退出运营' } as Record<string, string>)[operation] || operation || '未分类';
|
||||
const inventoryLabel = faultInventory(operation, row.vehicle_status, !!row.matched_vehicle_id);
|
||||
const assetCell: AssetAnalysisCell = {
|
||||
type: !row.matched_vehicle_id ? '未知车型' : type === '4.5T' ? (model.includes('冷链') ? '4.5T冷链' : '4.5T普货') : ['18T', '49T'].includes(type) ? type : '其他',
|
||||
model, city, region: mapMacroRegion(text(row.province_name), city),
|
||||
assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other',
|
||||
assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : '自营') : '其他', operationKind,
|
||||
inventoryState: ({ '异动': 'abnormal', '待交车': 'pending', '可运营': 'available', '待运营': 'awaiting', '未分类': 'unclassified' } as Record<string, AssetAnalysisCell['inventoryState']>)[inventoryLabel] || 'not-applicable',
|
||||
department: model === '公务车/挂靠车' ? '公务车' : text(row.business_dept) || '未分配部门',
|
||||
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), reportMethod: faultReportMethod(row),
|
||||
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,
|
||||
inventory: faultInventory(row.operation_status, row.vehicle_status, !!row.matched_vehicle_id),
|
||||
customer: customerKey,
|
||||
customerLabel: maskCustomerName(customer) || (unlinkedInventory ? '库存车' : '未关联客户'), department: text(row.business_dept) || '未关联部门',
|
||||
departmentName: text(row.business_dept), managerId: text(row.manager_id), subject: text(row.subject),
|
||||
missingVehicle: !row.matched_vehicle_id, handlers: faultHandlers(row, directory),
|
||||
};
|
||||
}
|
||||
export function visibleFaults(rows: ScopedFault[], user: AuthUser, subject?: string): ScopedFault[] {
|
||||
// Fault history never inherits the public-inventory exception for current assets.
|
||||
const candidates = user.permissionLevel === 'full' ? rows : rows.filter(row => !row.missingVehicle);
|
||||
const scoped = user.permissionLevel === 'department' && !user.depName ? [] : filterByPermission(candidates, user);
|
||||
return scoped.filter(row => !subject || row.subject === subject);
|
||||
}
|
||||
export function parseFaultQuery(params: Record<string, string>, now = new Date()): FaultQuery {
|
||||
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
const start = params.start || new Date(Date.parse(today) - 29 * 86400000).toISOString().slice(0, 10);
|
||||
const end = params.end || today;
|
||||
const valid = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString().slice(0, 10) === s;
|
||||
if (!valid(start) || !valid(end) || start > end || (Date.parse(end) - Date.parse(start)) / 86400000 > 365) throw new Error('日期范围须为有效日期,且不超过366天');
|
||||
if (params.day && (!valid(params.day) || params.day < start || params.day > end)) throw new Error('明细日期超出统计范围');
|
||||
const groupBy = params.groupBy || 'city';
|
||||
if (!(groupBy in FAULT_DIMENSIONS) || !Object.hasOwn(FAULT_DIMENSIONS, groupBy)) throw new Error('无效统计维度');
|
||||
const metric = params.metric || 'open';
|
||||
if (!['reported', 'closed', 'open'].includes(metric)) throw new Error('无效统计指标');
|
||||
const page = Number(params.page || 1);
|
||||
if (!Number.isSafeInteger(page) || page < 1) throw new Error('无效页码');
|
||||
return { start, end, groupBy: groupBy as FaultDimension, metric: metric as FaultQuery['metric'], page,
|
||||
day: params.day, groupValue: params.groupValue, subject: params.subject,
|
||||
filters: Object.fromEntries(Object.keys(FAULT_DIMENSIONS).filter(key => params[key]).map(key => [key, params[key]])) };
|
||||
}
|
||||
const dimensions = Object.keys(FAULT_DIMENSIONS) as FaultDimension[];
|
||||
function values(row: ScopedFault, dimension: FaultDimension): FaultOption[] {
|
||||
return dimension === 'handler' ? row.handlers : [{ value: row[dimension], label: dimension === 'customer' ? row.customerLabel : row[dimension] }];
|
||||
}
|
||||
export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updatedAt: string): FaultResponse {
|
||||
const options = Object.fromEntries(dimensions.map(d => [d, [...new Map(visible.flatMap(row => values(row, d)).map(o => [o.value, o])).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN'))])) as FaultResponse['options'];
|
||||
const rows = visible.filter(row => dimensions.every(d => !query.filters[d] || values(row, d).some(o => o.value === query.filters[d])));
|
||||
const inRange = (time: string | null) => !!time && time.slice(0, 10) >= query.start && time.slice(0, 10) <= query.end;
|
||||
const counts = (row: ScopedFault) => ({ reported: Number(inRange(row.reportedAt)), closed: Number(row.status === '已归档' && inRange(row.archivedAt)), open: Number(isOpenFault(row.status)) });
|
||||
const totals = { reported: 0, closed: 0, open: 0 };
|
||||
const groups = new Map<string, FaultResponse['groups'][number]>();
|
||||
const daily = new Map<string, FaultResponse['daily'][number]>();
|
||||
for (let ts = Date.parse(query.start); ts <= Date.parse(query.end); ts += 86400000) {
|
||||
const date = new Date(ts).toISOString().slice(0, 10); daily.set(date, { date, reported: 0, closed: 0 });
|
||||
}
|
||||
for (const row of rows) {
|
||||
const count = counts(row);
|
||||
for (const key of ['reported', 'closed', 'open'] as const) totals[key] += count[key];
|
||||
if (count.reported) daily.get(row.reportedAt!.slice(0, 10))!.reported++;
|
||||
if (count.closed) daily.get(row.archivedAt!.slice(0, 10))!.closed++;
|
||||
if (!count.reported && !count.closed && !count.open) continue;
|
||||
for (const option of values(row, query.groupBy)) {
|
||||
const group = groups.get(option.value) || { ...option, reported: 0, closed: 0, open: 0 };
|
||||
for (const key of ['reported', 'closed', 'open'] as const) group[key] += count[key];
|
||||
groups.set(option.value, group);
|
||||
}
|
||||
}
|
||||
const details = rows.filter(row => counts(row)[query.metric] &&
|
||||
(query.metric === 'open' || !query.day || (query.metric === 'reported' ? row.reportedAt : row.archivedAt)?.slice(0, 10) === query.day) &&
|
||||
(!query.groupValue || values(row, query.groupBy).some(o => o.value === query.groupValue)))
|
||||
.sort((a, b) => (query.metric === 'closed' ? b.archivedAt || '' : b.reportedAt || '').localeCompare(query.metric === 'closed' ? a.archivedAt || '' : a.reportedAt || '') || b.id.localeCompare(a.id));
|
||||
const pageSize = 50;
|
||||
const page = Math.min(query.page, Math.max(1, Math.ceil(details.length / pageSize)));
|
||||
return { start: query.start, end: query.end, updatedAt, totals, daily: [...daily.values()],
|
||||
groups: [...groups.values()].sort((a, b) => b.open - a.open || b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN')), options,
|
||||
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, vehicleKey, assetCell, ...row }) => row), total: details.length, page, pageSize },
|
||||
};
|
||||
}
|
||||
|
||||
export function faultBusinessDate(updatedAt: string): string {
|
||||
return new Date(Date.parse(updatedAt) + 8 * 3600000).toISOString().slice(0, 10);
|
||||
}
|
||||
export function isOverdueFault(row: FaultRecord, updatedAt: string): boolean {
|
||||
// OneOS supervisor aggregate: stored deadline only, suspended clock excluded, strict < NOW().
|
||||
if (!['待处理', '处理中'].includes(row.status) || !row.deadlineAt) return false;
|
||||
const deadline = Date.parse(`${row.deadlineAt.replace(' ', 'T')}+08:00`);
|
||||
return Number.isFinite(deadline) && deadline < Date.parse(updatedAt);
|
||||
}
|
||||
export function selectFaultMetric(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, date: string, updatedAt: string) {
|
||||
return rows.filter(row => {
|
||||
switch (metric) {
|
||||
case 'ledger': return true;
|
||||
// Current BI definition: in progress = waiting + processing; suspended remains in the ledger.
|
||||
case 'open': return ['待处理', '处理中'].includes(row.status);
|
||||
case 'waiting': return row.status === '待处理';
|
||||
case 'processing': return row.status === '处理中';
|
||||
case 'suspended': return row.status === '挂起';
|
||||
case 'overdue': return isOverdueFault(row, updatedAt);
|
||||
case 'reported': return row.reportedAt?.slice(0, 10) === date;
|
||||
case 'closed': return row.status === '已归档' && row.archivedAt?.slice(0, 10) === date;
|
||||
}
|
||||
});
|
||||
}
|
||||
export function faultOverview(rows: ScopedFault[], updatedAt: string): import('../../../shared/assets/faults.js').FaultOverview {
|
||||
const date = faultBusinessDate(updatedAt);
|
||||
return { date, updatedAt, totals: Object.fromEntries(
|
||||
(Object.keys(FAULT_OVERVIEW_METRICS) as import('../../../shared/assets/faults.js').FaultOverviewMetric[])
|
||||
.map(metric => [metric, selectFaultMetric(rows, metric, date, updatedAt).length]),
|
||||
) as import('../../../shared/assets/faults.js').FaultOverview['totals'] };
|
||||
}
|
||||
export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, updatedAt: string): import('../../../shared/assets/faults.js').FaultAnalysisResponse {
|
||||
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, vehicleKey, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db/mysql.js';
|
||||
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, f.report_method,
|
||||
CAST(f.source_work_order_id AS CHAR) source_work_order_id,
|
||||
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,
|
||||
DATE_FORMAT(f.deadline_at, '%Y-%m-%d %H:%i:%s') deadline_at,
|
||||
DATE_FORMAT(receipt.received_at, '%Y-%m-%d %H:%i:%s') received_at,
|
||||
CAST(vi.id AS CHAR) matched_vehicle_id, vi.operation_city,
|
||||
COALESCE(city.NAME, district.NAME) city_name, vi.registered_ownership subject,
|
||||
COALESCE(province.NAME, vi.province_name) province_name,
|
||||
vm.brand, vm.model model_name, vm.vehicle_type model_type, vi.body_color, vi.vehicle_source, vi.actual_ownership, vi.rental_company,
|
||||
vs.operation_status, vs.vehicle_status, lease.customer_name, lease.business_dept,
|
||||
lease.business_manager,
|
||||
CAST(COALESCE(contract.business_manager_id, vi.business_id) AS CHAR) manager_id,
|
||||
CAST(f.inspection_operator_id AS CHAR) handler_id, f.inspection_operator_name handler_name,
|
||||
f.inspection_operator_ids_json handler_ids, f.inspection_operator_names_json handler_names,
|
||||
CAST(wo.current_executor_user_id AS CHAR) work_id, wo.current_executor_name work_name,
|
||||
CAST(wo.current_executor_user_ids_json AS CHAR) work_ids,
|
||||
CAST(wo.current_executor_names_json AS CHAR) work_names
|
||||
FROM vehicle_fault_manage f
|
||||
LEFT JOIN (
|
||||
SELECT fault_id, MIN(create_time) received_at
|
||||
FROM vehicle_fault_operation_log
|
||||
WHERE del_flag='0' AND action_code='SAVE_PROGRESS'
|
||||
AND COALESCE(TRIM(before_status), '') IN ('', '待处理', '待处置', '未处理')
|
||||
AND TRIM(after_status) IN ('处理中', '处置中', '处理')
|
||||
GROUP BY fault_id
|
||||
) receipt ON receipt.fault_id=f.id
|
||||
LEFT JOIN vehicle_info vi ON vi.id=f.vehicle_id AND vi.del_flag='0'
|
||||
LEFT JOIN vehicle_model vm ON vm.id=vi.vehicle_model_id AND vm.del_flag='0'
|
||||
LEFT JOIN common_district province ON province.CODE=CONCAT(LEFT(vi.operation_city,2),'0000') COLLATE utf8mb4_unicode_ci AND province.STATUS='VALID'
|
||||
LEFT JOIN vehicle_status vs ON vs.id=(
|
||||
SELECT s.id FROM vehicle_status s WHERE s.vehicle_id=vi.id AND s.del_flag=0
|
||||
ORDER BY s.update_time DESC, s.id DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN vehicle_lease_order_record lease ON lease.id=(
|
||||
SELECT r.id FROM vehicle_lease_order_record r WHERE r.vehicle_id=vi.id AND r.del_flag='0'
|
||||
ORDER BY r.last_delivery_time DESC, r.id DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN vehicle_lease_contract_info contract ON contract.id=lease.contract_id AND contract.del_flag='0'
|
||||
LEFT JOIN common_district district ON district.CODE=vi.operation_city COLLATE utf8mb4_unicode_ci AND district.STATUS='VALID'
|
||||
LEFT JOIN common_district city ON city.CODE=(CASE
|
||||
WHEN LEFT(vi.operation_city,2) IN ('11','12','31','50') THEN CONCAT(LEFT(vi.operation_city,2),'0000')
|
||||
ELSE CONCAT(LEFT(vi.operation_city,4),'00') END) COLLATE utf8mb4_unicode_ci AND city.STATUS='VALID'
|
||||
LEFT JOIN task_work_order wo ON wo.id=f.source_work_order_id AND wo.del_flag='0'
|
||||
WHERE f.del_flag='0' AND f.is_submit IN (0,1)`;
|
||||
|
||||
export async function loadFaults() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY');
|
||||
const [raw] = await connection.query<RowDataPacket[]>({ sql: FAULT_SQL, timeout: 20000 });
|
||||
const [users] = await connection.query<RowDataPacket[]>({ sql: `SELECT CAST(user_id AS CHAR) id, nick_name name
|
||||
FROM \`ry-cloud\`.sys_user WHERE del_flag='0' AND status='0'
|
||||
AND (user_type IS NULL OR user_type='sys_user')`, timeout: 10000 });
|
||||
const directory = new Map<string, string>(users.map(row => [String(row.id), String(row.name || '').trim()]));
|
||||
// Defensive ID de-duplication keeps imperfect lookup tables from multiplying tickets.
|
||||
const rows = [...new Map(raw.map(row => [String(row.id), mapFault(row as RawFault, directory)])).values()];
|
||||
return { rows, updatedAt: new Date().toISOString() };
|
||||
} finally {
|
||||
try { await connection.rollback(); } finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { mapFault, type ScopedFault } from './model.js';
|
||||
import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js';
|
||||
import { createFaultRouter } from './index.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
const at = '2026-10-13T08:00:00Z';
|
||||
const row = (id: string, reported: string | null, status: string, archived: string | null = null): ScopedFault => mapFault({ id, reported_at: reported, handling_status: status, archived_at: archived, matched_vehicle_id: 'v1' }, new Map());
|
||||
const rows = [row('a', '2026-09-30 23:59:59', '已归档', '2026-10-12 12:00:00'),
|
||||
row('b', '2026-10-01 00:00:00', '处理中'), row('c', '2026-10-01 08:00:00', '挂起'),
|
||||
row('d', '2026-10-03 23:59:59', '待处理'), row('before', '2026-09-29 23:59:59', '已归档', '2026-10-02 00:00:00'),
|
||||
row('after', '2026-10-04 00:00:00', '已归档'), row('missing', null, '已归档')];
|
||||
const query = (params: Record<string, string> = {}) => parseFaultTrendQuery({ start: '2026-09-30', end: '2026-10-03', ...params });
|
||||
|
||||
test('按提报日统计同批新增的当前闭环情况,跨期归档仍属于原提报批次,空日补零', () => {
|
||||
const data = faultTrend(rows, query(), at);
|
||||
assert.deepEqual(data.totals, { reported: 4, closed: 1, unclosed: 3 });
|
||||
assert.deepEqual(data.periods.map(p => [p.period, p.reported, p.closed, p.unclosed]), [
|
||||
['2026-09-30', 1, 1, 0], ['2026-10-01', 2, 0, 2], ['2026-10-02', 0, 0, 0], ['2026-10-03', 1, 0, 1],
|
||||
]);
|
||||
for (const period of data.periods) assert.equal(period.reported, period.closed + period.unclosed);
|
||||
assert.equal(data.periods.reduce((sum, p) => sum + p.reported, 0), data.totals.reported);
|
||||
});
|
||||
test('按月不扩展所选首尾边界,日月总数一致;汇总与各期下钻匹配', () => {
|
||||
const data = faultTrend(rows, query({ granularity: 'month' }), at);
|
||||
assert.deepEqual(data.totals, faultTrend(rows, query(), at).totals);
|
||||
assert.deepEqual(data.periods.map(p => [p.period, p.reported]), [['2026-09', 1], ['2026-10', 3]]);
|
||||
for (const granularity of ['day', 'month']) {
|
||||
for (const period of faultTrend(rows, query({ granularity }), at).periods) {
|
||||
for (const [status, key] of [['all', 'reported'], ['closed', 'closed'], ['unclosed', 'unclosed']] as const) {
|
||||
assert.equal(faultTrendAnalysis(rows, query({ granularity, period: period.period, status }), at).rows.length, period[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
test('空范围、闰日、非法周期及370天范围限制', () => {
|
||||
assert.equal(faultTrend([], query(), at).periods.length, 4);
|
||||
assert.deepEqual(faultTrend([], query(), at).totals, { reported: 0, closed: 0, unclosed: 0 });
|
||||
assert.doesNotThrow(() => query({ start: '2024-02-29', end: '2024-02-29' }));
|
||||
const invalid: Record<string, string>[] = [{ start: '2026-02-30' }, { end: '2026-01-01' }, { granularity: 'year' }, { status: '__proto__' },
|
||||
{ period: '2026-09-29' }, { granularity: 'month', period: '2026-13' }, { start: '2025-01-01' }];
|
||||
for (const params of invalid) assert.throws(() => query(params));
|
||||
});
|
||||
test('趋势和下钻都先应用人员权限,未认证及无效参数不查询数据库', async () => {
|
||||
let calls = 0;
|
||||
const own = { ...rows[0], managerId: 'mine' }, hidden = { ...rows[1], managerId: 'secret', code: 'PRIVATE' };
|
||||
const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [own, hidden] }; });
|
||||
assert.equal((await router.request('/trend')).status, 401);
|
||||
assert.equal((await router.request('/trend-analysis')).status, 401);
|
||||
const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] };
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router);
|
||||
assert.equal((await app.request('/faults/trend?start=bad')).status, 400); assert.equal(calls, 0);
|
||||
const params = '?start=2026-09-30&end=2026-10-03&granularity=month';
|
||||
const response = await app.request(`/faults/trend${params}`);
|
||||
assert.equal(response.headers.get('Cache-Control'), 'no-store');
|
||||
const data = await response.json(); assert.equal(data.totals.reported, 1);
|
||||
const detail = await (await app.request(`/faults/trend-analysis${params}&period=2026-09&status=closed`)).json();
|
||||
assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE'));
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { FaultTrendQuery, FaultTrendResponse } from '../../../shared/assets/faults.js';
|
||||
import { faultAnalysis, parseFaultQuery, type ScopedFault } from './model.js';
|
||||
|
||||
export function parseFaultTrendQuery(params: Record<string, string>): FaultTrendQuery {
|
||||
const defaults = parseFaultQuery({});
|
||||
const start = params.start || defaults.start, end = params.end || defaults.end;
|
||||
const valid = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
|
||||
if (!valid(start) || !valid(end) || start > end || Date.parse(end) - Date.parse(start) > 369 * 86400000) throw new Error('请选择有效日期范围,最多370天');
|
||||
const granularity = params.granularity || 'day';
|
||||
if (granularity !== 'day' && granularity !== 'month') throw new Error('无效时间粒度');
|
||||
const status = params.status || 'all';
|
||||
if (!['all', 'closed', 'unclosed'].includes(status)) throw new Error('无效闭环状态');
|
||||
const length = granularity === 'day' ? 10 : 7;
|
||||
if (params.period && (params.period.length !== length || params.period < start.slice(0, length) || params.period > end.slice(0, length)
|
||||
|| !(granularity === 'day' ? valid(params.period) : valid(`${params.period}-01`)))) throw new Error('统计周期超出所选范围');
|
||||
return { start, end, granularity, status: status as FaultTrendQuery['status'], period: params.period };
|
||||
}
|
||||
export function selectFaultCohort(rows: ScopedFault[], query: FaultTrendQuery) {
|
||||
return rows.filter(row => {
|
||||
const day = row.reportedAt?.slice(0, 10);
|
||||
return day && day >= query.start && day <= query.end
|
||||
&& (!query.period || day.slice(0, query.granularity === 'day' ? 10 : 7) === query.period)
|
||||
&& (query.status === 'all' || (row.status === '已归档') === (query.status === 'closed'));
|
||||
});
|
||||
}
|
||||
export function faultTrend(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string): FaultTrendResponse {
|
||||
const periods = new Map<string, FaultTrendResponse['periods'][number]>();
|
||||
for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) {
|
||||
const period = new Date(time).toISOString().slice(0, query.granularity === 'day' ? 10 : 7);
|
||||
if (!periods.has(period)) periods.set(period, { period, reported: 0, closed: 0, unclosed: 0 });
|
||||
}
|
||||
const totals = { reported: 0, closed: 0, unclosed: 0 };
|
||||
for (const row of selectFaultCohort(rows, { ...query, status: 'all', period: undefined })) {
|
||||
const period = periods.get(row.reportedAt!.slice(0, query.granularity === 'day' ? 10 : 7))!;
|
||||
const status = row.status === '已归档' ? 'closed' : 'unclosed';
|
||||
period.reported++; period[status]++; totals.reported++; totals[status]++;
|
||||
}
|
||||
return { start: query.start, end: query.end, granularity: query.granularity, updatedAt, totals, periods: [...periods.values()] };
|
||||
}
|
||||
export function faultTrendAnalysis(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string) {
|
||||
return faultAnalysis(selectFaultCohort(rows, query), 'ledger', updatedAt);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { mapFault, type ScopedFault } from './model.js';
|
||||
import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js';
|
||||
import { createFaultRouter } from './index.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
const at = '2026-10-13T08:00:00Z';
|
||||
const query = (params: Record<string, string> = {}) => parseFaultWeeklyQuery({ start: '2026-10-10', end: '2026-10-16', ...params });
|
||||
const fault = (id: string, fields: Record<string, unknown> = {}): ScopedFault => mapFault({ id, matched_vehicle_id: 'v1', ...fields }, new Map());
|
||||
const rows = [fault('old', { reported_at: '2026-09-01 08:00:00', received_at: '2026-10-10 00:00:00', archived_at: '2026-10-13 23:59:59', handling_status: '已归档' }),
|
||||
fault('new', { reported_at: '2026-10-10 23:59:59', received_at: '2026-10-11 00:00:00', handling_status: '挂起' }),
|
||||
fault('missing', { handling_status: '处理中' }),
|
||||
fault('before', { received_at: '2026-10-09 23:59:59' }),
|
||||
fault('after', { reported_at: '2026-10-17 00:00:00', received_at: '2026-10-17 00:00:00' }),
|
||||
fault('not-archived', { archived_at: '2026-10-12 08:00:00', handling_status: '处理中' })];
|
||||
|
||||
test('周报按各自事件日期独立统计,历史提报可本周接收与闭环,缺事件时间不推断', () => {
|
||||
const result = faultWeeklyReport(rows, query(), at);
|
||||
assert.deepEqual(result.totals, { reported: 1, received: 2, closed: 1 });
|
||||
assert.equal(result.days.length, 7);
|
||||
assert.deepEqual(result.days[0], { date: '2026-10-10', reported: 1, received: 1, closed: 0 });
|
||||
assert.equal(result.days[1].received, 1); assert.equal(result.days[3].closed, 1);
|
||||
assert.deepEqual(result.days[6], { date: '2026-10-16', reported: 0, received: 0, closed: 0 });
|
||||
for (const metric of ['reported', 'received', 'closed'] as const) {
|
||||
assert.equal(result.totals[metric], result.days.reduce((n, day) => n + day[metric], 0));
|
||||
assert.equal(faultWeeklyAnalysis(rows, query({ metric }), at).rows.length, result.totals[metric]);
|
||||
for (const day of result.days) assert.equal(faultWeeklyAnalysis(rows, query({ metric, day: day.date }), at).rows.length, day[metric]);
|
||||
}
|
||||
assert.equal(faultWeeklyAnalysis(rows, query({ metric: 'received', day: '2026-10-11' }), at).rows[0].receivedAt, '2026-10-11 00:00:00');
|
||||
});
|
||||
test('周报时间与指标校验,自选单日和无数据日期补零', () => {
|
||||
const invalid: Record<string, string>[] = [{ metric: '__proto__' }, { start: 'bad' }, { end: '2026-01-01' }, { day: '2026-10-17' }, { day: '2026-10' }];
|
||||
for (const params of invalid) assert.throws(() => query(params));
|
||||
const single = faultWeeklyReport([], query({ start: '2026-10-13', end: '2026-10-13' }), at);
|
||||
assert.deepEqual(single.days, [{ date: '2026-10-13', reported: 0, received: 0, closed: 0 }]);
|
||||
});
|
||||
test('周报汇总和接收处理下钻先过滤权限,未认证与无效参数不查询', async () => {
|
||||
let calls = 0;
|
||||
const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [{ ...rows[0], managerId: 'mine' }, { ...rows[1], managerId: 'other', code: 'PRIVATE' }] }; });
|
||||
for (const path of ['/weekly-report', '/weekly-report-analysis']) assert.equal((await router.request(path)).status, 401);
|
||||
const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] };
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router);
|
||||
assert.equal((await app.request('/faults/weekly-report?start=bad')).status, 400); assert.equal(calls, 0);
|
||||
const params = '?start=2026-10-10&end=2026-10-16&metric=received';
|
||||
const response = await app.request(`/faults/weekly-report${params}`);
|
||||
assert.equal(response.headers.get('Cache-Control'), 'no-store');
|
||||
const data = await response.json(); assert.equal(data.totals.received, 1);
|
||||
const detail = await (await app.request(`/faults/weekly-report-analysis${params}`)).json();
|
||||
assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE'));
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FAULT_WEEKLY_METRICS, type FaultWeeklyQuery, type FaultWeeklyResponse } from '../../../shared/assets/faults.js';
|
||||
import { faultAnalysis, type ScopedFault } from './model.js';
|
||||
import { parseFaultTrendQuery } from './trend.js';
|
||||
|
||||
export function parseFaultWeeklyQuery(params: Record<string, string>): FaultWeeklyQuery {
|
||||
const range = parseFaultTrendQuery({ start: params.start, end: params.end, granularity: 'day', period: params.day });
|
||||
const metric = params.metric || 'reported';
|
||||
if (!Object.hasOwn(FAULT_WEEKLY_METRICS, metric)) throw new Error('无效周报指标');
|
||||
return { start: range.start, end: range.end, day: range.period, metric: metric as FaultWeeklyQuery['metric'] };
|
||||
}
|
||||
function eventDay(row: ScopedFault, metric: FaultWeeklyQuery['metric']) {
|
||||
return (metric === 'reported' ? row.reportedAt : metric === 'received' ? row.receivedAt : row.status === '已归档' ? row.archivedAt : null)?.slice(0, 10);
|
||||
}
|
||||
export function faultWeeklyReport(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string): FaultWeeklyResponse {
|
||||
const days = new Map<string, FaultWeeklyResponse['days'][number]>();
|
||||
for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) {
|
||||
const date = new Date(time).toISOString().slice(0, 10); days.set(date, { date, reported: 0, received: 0, closed: 0 });
|
||||
}
|
||||
const totals = { reported: 0, received: 0, closed: 0 };
|
||||
for (const row of rows) for (const metric of Object.keys(FAULT_WEEKLY_METRICS) as FaultWeeklyQuery['metric'][]) {
|
||||
const day = days.get(eventDay(row, metric) || '');
|
||||
if (day) { day[metric]++; totals[metric]++; }
|
||||
}
|
||||
return { start: query.start, end: query.end, updatedAt, totals, days: [...days.values()] };
|
||||
}
|
||||
export function faultWeeklyAnalysis(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string) {
|
||||
return faultAnalysis(rows.filter(row => {
|
||||
const date = eventDay(row, query.metric);
|
||||
return date && date >= query.start && date <= query.end && (!query.day || date === query.day);
|
||||
}), 'ledger', updatedAt);
|
||||
}
|
||||
Reference in New Issue
Block a user