feat(assets): 完善故障提报来源与重点跟进展示
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-10-16 18:15:44 +08:00
parent f107e96d3c
commit 1674f052d0
11 changed files with 166 additions and 45 deletions
@@ -40,3 +40,97 @@ test('dashboard and report endpoints enforce authentication, permissions, dates
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}&region=不存在`)).json();
assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0);
});
+14 -6
View File
@@ -1,4 +1,4 @@
import { FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js';
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';
@@ -7,9 +7,11 @@ export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension):
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>) {
@@ -18,15 +20,19 @@ export function filterDashboardRows(rows: ScopedFault[], params: Record<string,
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('无效统计范围');
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);
return faultAnalysis(selected, query.scope === 'cohort' ? 'ledger' : query.scope, updatedAt);
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);
@@ -56,9 +62,11 @@ export function faultDashboard(visible: ScopedFault[], params: Record<string, st
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,
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 },
+9 -2
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto';
import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js';
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';
@@ -28,6 +28,13 @@ function array(value: unknown, preserveIds = false): unknown[] {
}
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)],
@@ -81,7 +88,7 @@ export function mapFault(row: RawFault, directory: Map<string, string>): ScopedF
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),
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,
+2 -1
View File
@@ -4,7 +4,8 @@ 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.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,