This commit is contained in:
@@ -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}®ion=不存在`)).json();
|
||||
assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user