feat(assets): 完成故障维修运维 BI 与 Excel 报表导出
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { Hono } from 'hono';
|
||||
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 ['/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 = visibleFaults(data.rows, user);
|
||||
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 = visibleFaults(data.rows, user);
|
||||
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,194 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { analyzeFaults, faultAnalysis, faultHandlers, faultInventory, mapFault, normalizeFaultStatus, parseFaultQuery, visibleFaults, type ScopedFault } from './model.js';
|
||||
import { createFaultRouter } from './index.js';
|
||||
import type { AuthUser } from '../../auth/types.js';
|
||||
|
||||
const full: AuthUser = { userId: 'm1', userName: 'User', loginName: 'user', depCode: '', depName: '业务一部', permissionLevel: 'full', roles: [] };
|
||||
function fault(id: string, overrides: Partial<ScopedFault> = {}): ScopedFault {
|
||||
return { assetCell: mapFault({ id }, new Map()).assetCell, id, code: id, plate: '测试车', description: '', reportedAt: '2026-10-01 12:00:00', archivedAt: null, deadlineAt: null, receivedAt: null,
|
||||
city: '广州市', status: '待处理', inventory: '不适用', customer: 'customer1', customerLabel: '测试***户', department: '业务一部',
|
||||
handlers: [{ value: 'id:1', label: '甲' }], subject: 'A', departmentName: '业务一部', managerId: 'm1', missingVehicle: false, ...overrides };
|
||||
}
|
||||
const query = (params: Record<string, string> = {}) => parseFaultQuery({ start: '2026-10-13', end: '2026-10-13', ...params });
|
||||
|
||||
test('跨日完结独立于提报日期,全部挂单不受日期筛选影响,挂起和审核中均未闭环', () => {
|
||||
const rows = [fault('old'), fault('suspended', { status: '挂起' }), fault('review', { status: '处理中' }),
|
||||
fault('closed', { status: '已归档', archivedAt: '2026-10-13 00:00:00' }),
|
||||
fault('new', { reportedAt: '2026-10-13 23:59:59' }), fault('tomorrow', { status: '已归档', archivedAt: '2026-10-14 00:00:00' })];
|
||||
const result = analyzeFaults(rows, query(), 'now');
|
||||
assert.deepEqual(result.totals, { reported: 1, closed: 1, open: 4 });
|
||||
assert.equal(result.details.total, 4);
|
||||
assert.deepEqual(result.daily, [{ date: '2026-10-13', reported: 1, closed: 1 }]);
|
||||
assert.equal(analyzeFaults(rows, query({ start: '2025-01-01', end: '2025-01-02', day: '2025-01-01' }), 'now').totals.open, 4);
|
||||
assert.equal(analyzeFaults(rows, query({ metric: 'closed' }), 'now').details.rows[0].id, 'closed');
|
||||
});
|
||||
|
||||
test('多人负责按人员计单,总计去重;人员筛选与人员分组下钻一致', () => {
|
||||
const row = fault('shared', { handlers: [{ value: 'id:1', label: '甲' }, { value: 'id:2', label: '乙' }] });
|
||||
const result = analyzeFaults([row], query({ groupBy: 'handler' }), 'now');
|
||||
assert.equal(result.totals.open, 1); assert.equal(result.groups.reduce((sum, r) => sum + r.open, 0), 2);
|
||||
assert.equal(analyzeFaults([row], query({ handler: 'id:2' }), 'now').details.total, 1);
|
||||
assert.equal(analyzeFaults([row], query({ groupBy: 'handler', groupValue: 'id:2' }), 'now').details.total, 1);
|
||||
});
|
||||
|
||||
test('处置人优先来源任务工单,兼容历史姓名,保留 BIGINT 精度并去重', () => {
|
||||
const directory = new Map([['1234567890123456789', '当前姓名']]);
|
||||
const row = { work_ids: '[1234567890123456789,1234567890123456789]', work_names: '["旧名","旧名"]', handler_id: '2', handler_name: '非当前处置人' };
|
||||
assert.deepEqual(faultHandlers(row, directory), [{ value: 'id:1234567890123456789', label: '当前姓名' }]);
|
||||
assert.deepEqual(faultHandlers({ work_names: '["历史姓名"]', handler_id: '2' }, directory), [{ value: 'name:历史姓名', label: '历史姓名' }]);
|
||||
assert.deepEqual(faultHandlers({ handler_ids: 'bad-json', handler_id: '99' }, directory), [{ value: 'id:99', label: '历史处理人(99)' }]);
|
||||
assert.deepEqual(faultHandlers({ handler_ids: '[99,99]', handler_names: '["历史[123]","另一姓名"]' }, directory), [{ value: 'id:99', label: '历史[123]' }]);
|
||||
assert.deepEqual(faultHandlers({}, directory), [{ value: 'unassigned', label: '未指派' }]);
|
||||
});
|
||||
|
||||
test('库存先判断运营状态,再细分;未知状态不自动计为归档', () => {
|
||||
assert.equal(faultInventory('1', '14', true), '不适用');
|
||||
assert.equal(faultInventory('3', '14', true), '异动');
|
||||
assert.equal(faultInventory('4', '4', true), '待交车');
|
||||
assert.equal(faultInventory('3', '2', true), '可运营');
|
||||
assert.equal(faultInventory('4', '2', true), '待运营');
|
||||
assert.equal(faultInventory('5', '2', true), '不适用');
|
||||
assert.equal(faultInventory(null, null, false), '未知');
|
||||
assert.equal(normalizeFaultStatus('待处置'), '待处理');
|
||||
assert.equal(normalizeFaultStatus('已挂起'), '挂起');
|
||||
assert.equal(analyzeFaults([fault('odd', { status: '其他状态' })], query(), 'now').totals.open, 0);
|
||||
});
|
||||
|
||||
test('缺失车辆和日期保留总量及可用归档事件,客户名称脱敏且不传递服务端权限字段', () => {
|
||||
const row = mapFault({ id: 'lost', fault_code: 'F', customer_name: '需要脱敏的客户名称', handling_status: '已归档', archived_at: '2026-10-13 08:00:00' }, new Map());
|
||||
const result = analyzeFaults([row], query({ metric: 'closed' }), 'now');
|
||||
assert.deepEqual(result.totals, { reported: 0, closed: 1, open: 0 });
|
||||
assert.equal(result.quality.missingReport, 1); assert.equal(result.quality.missingVehicle, 1);
|
||||
assert.equal(result.details.rows[0].city, '未知');
|
||||
assert.ok(!JSON.stringify(result).includes('需要脱敏的客户名称'));
|
||||
assert.ok(!('managerId' in result.details.rows[0]));
|
||||
});
|
||||
|
||||
test('故障历史使用严格人员/部门权限,库存不放宽;全权限保留失联车辆', () => {
|
||||
const rows = [fault('mine'), fault('private', { managerId: 'm2', departmentName: '其他部', department: '其他部', inventory: '可运营' }), fault('lost', { managerId: '', departmentName: '', missingVehicle: true })];
|
||||
assert.equal(visibleFaults(rows, full).length, 3);
|
||||
assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'personal' }).map(r => r.id), ['mine']);
|
||||
assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'department' }).map(r => r.id), ['mine']);
|
||||
assert.equal(visibleFaults(rows, { ...full, permissionLevel: 'department', depName: '' }).length, 0);
|
||||
assert.equal(visibleFaults(rows, full, 'B').length, 0);
|
||||
});
|
||||
|
||||
test('日期合法性、范围限制和分页边界', () => {
|
||||
const invalid: Record<string, string>[] = [{ start: '2026-02-30' }, { start: '2027-01-01' }, { start: '2023-01-01' }, { page: '-1' }, { groupBy: '__proto__' }, { metric: 'unknown' }, { day: '2026-10-14' }];
|
||||
for (const params of invalid) assert.throws(() => query(params));
|
||||
const rows = Array.from({ length: 51 }, (_, i) => fault(String(i)));
|
||||
const result = analyzeFaults(rows, query({ page: '99' }), 'now');
|
||||
assert.equal(result.details.page, 2); assert.equal(result.details.rows.length, 1);
|
||||
});
|
||||
|
||||
test('API 在汇总、筛选项和明细前应用权限,未认证和无效日期不查询数据库', async () => {
|
||||
let calls = 0;
|
||||
const router = createFaultRouter(async () => { calls++; return { updatedAt: 'now', rows: [fault('mine'), fault('secret', { managerId: 'm2', city: '保密城市' })] }; });
|
||||
assert.equal((await router.request('/')).status, 401); assert.equal(calls, 0);
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); });
|
||||
app.route('/faults', router);
|
||||
assert.equal((await app.request('/faults?start=bad')).status, 400); assert.equal(calls, 0);
|
||||
const response = await app.request('/faults?start=2026-10-13&end=2026-10-13');
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('Cache-Control'), 'no-store');
|
||||
const body = await response.json(); assert.equal(body.totals.open, 1); assert.ok(!JSON.stringify(body).includes('保密城市'));
|
||||
});
|
||||
|
||||
test('四个总指标使用北京时间自然日,全部台账包含未知状态和无日期故障', async () => {
|
||||
const { faultOverview, faultAnalysis } = await import('./model.js');
|
||||
const rows = [fault('old'), fault('today', { reportedAt: '2026-10-14 00:00:00' }),
|
||||
fault('closed', { status: '已归档', archivedAt: '2026-10-14 23:59:59' }),
|
||||
fault('unknown', { status: '未知状态', reportedAt: null, missingVehicle: true })];
|
||||
const at = '2026-10-13T16:00:00Z';
|
||||
const summary = faultOverview(rows, at);
|
||||
assert.equal(summary.date, '2026-10-14');
|
||||
assert.deepEqual(summary.totals, { reported: 1, closed: 1, open: 2, waiting: 2, processing: 0, suspended: 0, overdue: 0, ledger: 4 });
|
||||
for (const metric of ['reported', 'closed', 'open', 'ledger'] as const) {
|
||||
const analysis = faultAnalysis(rows, metric, at);
|
||||
assert.equal(analysis.rows.length, summary.totals[metric]);
|
||||
assert.equal(analysis.rows.reduce((sum, row) => sum + row.cell.quantity, 0), summary.totals[metric]);
|
||||
assert.ok(!JSON.stringify(analysis).includes('managerId'));
|
||||
}
|
||||
assert.equal(faultOverview(rows, '2026-10-13T15:59:59Z').totals.reported, 0);
|
||||
});
|
||||
|
||||
test('故障下钻与资产维度映射同步,同车多故障逐单统计,脱敏客户保持独立', async () => {
|
||||
const { faultAnalysis } = await import('./model.js');
|
||||
const { getAvailableDimensions, getCustomAnalysis } = await import('../../../modules/assets/analytics-model.js');
|
||||
const raw = { matched_vehicle_id: 'V1', model_name: '公务用车/小客车', brand: 'denza', vehicle_source: '0',
|
||||
city_name: '上海市', province_name: '上海市', operation_status: '2', business_dept: '业务部', business_manager: '经理甲' };
|
||||
const rows = [mapFault({ ...raw, id: 'F1', customer_name: '客户甲' }, new Map()), mapFault({ ...raw, id: 'F2', customer_name: '客户乙' }, new Map())];
|
||||
const data = faultAnalysis(rows, 'ledger', '2026-10-13T06:00:00Z');
|
||||
assert.equal(data.rows[0].cell.model, '公务车/挂靠车');
|
||||
assert.equal(data.rows[0].cell.department, '公务车');
|
||||
assert.equal(data.rows[0].cell.region, '华东');
|
||||
assert.equal(data.rows[0].cell.manager, '经理甲');
|
||||
assert.equal(data.rows[0].cell.assetDisposition, '自营');
|
||||
assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['model'] }).rows[0].count, 2);
|
||||
assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['customer'] }).rows.length, 2);
|
||||
assert.equal(getAvailableDimensions('assets').length, 9);
|
||||
});
|
||||
|
||||
test('总览与下钻接口先应用权限,无效指标和未认证不查询', async () => {
|
||||
let calls = 0;
|
||||
const router = createFaultRouter(async () => { calls++; return { updatedAt: '2026-10-13T06:00:00Z', rows: [fault('mine'), fault('secret', { managerId: 'm2', customer: 'secret-customer', customerLabel: '保密客户' })] }; });
|
||||
for (const url of ['/overview', '/analysis?metric=ledger']) assert.equal((await router.request(url)).status, 401);
|
||||
assert.equal(calls, 0);
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); });
|
||||
app.route('/faults', router);
|
||||
assert.equal((await app.request('/faults/analysis?metric=__proto__')).status, 400);
|
||||
assert.equal(calls, 0);
|
||||
const summary = await (await app.request('/faults/overview')).json();
|
||||
assert.equal(summary.totals.ledger, 1);
|
||||
const response = await app.request('/faults/analysis?metric=ledger');
|
||||
const data = await response.json();
|
||||
assert.equal(response.headers.get('Cache-Control'), 'no-store');
|
||||
assert.equal(data.rows.length, summary.totals.ledger);
|
||||
assert.ok(!JSON.stringify(data).includes('secret'));
|
||||
});
|
||||
|
||||
test('在办按状态细分;逾期以北京时间截止时间严格比较,排除挂起、归档和无截止时间', async () => {
|
||||
const { faultOverview, faultAnalysis } = await import('./model.js');
|
||||
const at = '2026-10-13T06:00:00Z';
|
||||
const rows = [fault('waiting-overdue', { deadlineAt: '2026-10-13 13:59:59' }),
|
||||
fault('processing-overdue', { status: '处理中', deadlineAt: '2026-10-12 14:00:00' }),
|
||||
fault('boundary', { deadlineAt: '2026-10-13 14:00:00' }),
|
||||
fault('future', { status: '处理中', deadlineAt: '2026-10-13 14:00:01' }),
|
||||
fault('missing'), fault('invalid', { deadlineAt: 'invalid' }),
|
||||
fault('suspended', { status: '挂起', deadlineAt: '2026-01-01 00:00:00' }),
|
||||
fault('archived', { status: '已归档', deadlineAt: '2026-01-01 00:00:00' })];
|
||||
const summary = faultOverview(rows, at);
|
||||
assert.equal(summary.totals.waiting, 4);
|
||||
assert.equal(summary.totals.processing, 2);
|
||||
assert.equal(summary.totals.suspended, 1);
|
||||
assert.equal(summary.totals.open, 6);
|
||||
assert.equal(summary.totals.overdue, 2);
|
||||
assert.equal(summary.totals.open, summary.totals.waiting + summary.totals.processing);
|
||||
for (const metric of ['open', 'waiting', 'processing', 'suspended', 'overdue'] as const) {
|
||||
assert.equal(faultAnalysis(rows, metric, at).rows.length, summary.totals[metric]);
|
||||
}
|
||||
const openIds = new Set(faultAnalysis(rows, 'open', at).rows.map(r => r.id));
|
||||
assert.ok(faultAnalysis(rows, 'overdue', at).rows.every(row => openIds.has(row.id)));
|
||||
assert.deepEqual(faultAnalysis(rows, 'overdue', at).rows.map(r => r.id), ['waiting-overdue', 'processing-overdue']);
|
||||
});
|
||||
|
||||
|
||||
test('未关联客户的库存车单独展示,不混入非库存、未知车辆或已有客户', () => {
|
||||
const make = (extra: Record<string, unknown>) => mapFault({ id: 'stock', matched_vehicle_id: 'v1', operation_status: '3', ...extra }, new Map());
|
||||
for (const operation_status of ['3', '4', '可运营', '待运营', '在库']) {
|
||||
const row = make({ operation_status });
|
||||
assert.equal(row.customer, 'inventory-unlinked');
|
||||
assert.equal(row.assetCell.customer, 'inventory-unlinked');
|
||||
assert.equal(row.customerLabel, '库存车');
|
||||
}
|
||||
for (const extra of [{ operation_status: '1' }, { operation_status: '5' }, { operation_status: null }, { matched_vehicle_id: null }]) {
|
||||
const row = make(extra); assert.equal(row.customer, 'unlinked'); assert.equal(row.customerLabel, '未关联客户');
|
||||
}
|
||||
const assigned = make({ customer_name: '测试客户名称' });
|
||||
assert.notEqual(assigned.customer, 'inventory-unlinked');assert.notEqual(assigned.customerLabel, '库存车');
|
||||
const result = faultAnalysis([make({}), make({ id: 'other', operation_status: '1' })], 'ledger', '2026-10-13T00:00:00Z');
|
||||
assert.equal(result.customerLabels['inventory-unlinked'], '库存车');assert.equal(result.customerLabels.unlinked, '未关联客户');assert.equal(result.rows.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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 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;
|
||||
}
|
||||
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));
|
||||
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 {
|
||||
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, 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, assetCell, ...row }) => ({ ...row, cell: assetCell })) };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
import pool from '../../db.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
|
||||
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