feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -17,9 +17,10 @@ describe('access domain helpers', () => {
});
it('exports explicit state and evidence fields', () => {
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2 }]);
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2, expectedProtocols: ['GB32960', 'JT808', 'YUTONG_MQTT'], actualProtocols: ['JT808'], missingProtocols: ['GB32960', 'YUTONG_MQTT'], protocolStatuses: [{ protocol: 'JT808', expected: true, connected: true, provider: 'G7', firstSeenAt: '2026-07-01T00:00:00+08:00', latestEventAt: '', latestReceivedAt: '2026-07-15T09:00:00+08:00', reportIntervalSec: 10, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, delayAbnormal: false, firstSeenEvidence: '网关首次观测', reportIntervalEvidence: '连续样本' }], connectionState: 'incomplete', expectationEvidence: '平台标准接入基线' }]);
expect(csv).toContain('在线');
expect(csv).toContain('"位置,数据"');
expect(csv).toContain('"GB32960/YUTONG_MQTT"');
expect(csv).toContain('"2026-07-15T09:00:00+08:00"');
expect(csv).toContain('"示范企业"');
});
});

View File

@@ -38,8 +38,15 @@ export function updateProtocolThreshold(items: AccessProtocolThreshold[], protoc
}
export function accessRowsToCSV(rows: AccessVehicleRow[]) {
const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误'];
const protocolColumns = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => [`${protocol}接入状态`, `${protocol}接入厂家`, `${protocol}首次接入`, `${protocol}最新上报`, `${protocol}离线秒数`]);
const columns = ['综合状态', '车牌', 'VIN', '品牌', '车型', '企业', '应接协议', '实际接入协议', '缺失协议', ...protocolColumns];
const quote = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = rows.map((row) => [accessStateLabels[row.onlineState], row.plate, row.vin, row.oem, row.model, row.company, row.protocol, row.provider, row.firstSeenAt, row.firstSeenEvidence, row.latestEventAt, row.latestReceivedAt, row.reportIntervalSec, row.reportSampleCount, row.reportIntervalEvidence, row.dataDelaySec, row.thresholdSec, row.latestMessageType, row.latestError].map(quote).join(','));
const lines = rows.map((row) => {
const protocolValues = ['GB32960', 'JT808', 'YUTONG_MQTT'].flatMap((protocol) => {
const status = row.protocolStatuses.find((item) => item.protocol === protocol);
return [status?.connected ? accessStateLabels[status.onlineState] : '未接入', status?.provider, status?.firstSeenAt, status?.latestReceivedAt, status?.freshnessSec];
});
return [row.connectionState, row.plate, row.vin, row.oem, row.model, row.company, row.expectedProtocols.join('/'), row.actualProtocols.join('/'), row.missingProtocols.join('/'), ...protocolValues].map(quote).join(',');
});
return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`;
}

View File

@@ -0,0 +1,38 @@
import { expect, test } from 'vitest';
import { createMileageWorkbook } from './mileageExport';
test('creates a styled numeric mileage workbook with formulas and frozen panes', async () => {
const workbook = await createMileageWorkbook({
dateFrom: '2026-07-13',
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [
{ vin: 'LTEST000000000001', plate: '粤A12345' },
{ vin: 'LTEST000000000002', plate: '粤A54321' }
],
mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, source: 'GB32960' }
],
sources: [
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程' },
{ protocol: 'JT808', label: '交通部 JT/T 808', mileageType: 'GPS 里程' }
],
exportedAt: new Date('2026-07-15T08:00:00+08:00')
});
const sheet = workbook.getWorksheet('里程查询')!;
expect(sheet.getCell('A1').value).toBe('车辆里程查询明细');
expect(sheet.getCell('C6').value).toBeInstanceOf(Date);
expect(sheet.getCell('C6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('D6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'center' });
expect(sheet.getCell('A6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'left' });
expect(sheet.getCell('E6').alignment).toMatchObject({ vertical: 'middle', horizontal: 'right' });
expect(sheet.getCell('C7').value).toBe(88.7);
expect(sheet.getCell('E7').value).toMatchObject({ formula: 'SUM(C7:D7)', result: 193.3 });
expect(sheet.getCell('E7').numFmt).toBe('#,##0.0" km"');
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});

View File

@@ -0,0 +1,161 @@
import type { DailyMileageRow } from '../../api/types';
export type MileageExportVehicle = { vin: string; plate: string };
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
export type MileageExportInput = {
dateFrom: string;
dateTo: string;
dates: string[];
vehicles: MileageExportVehicle[];
mileageRows: DailyMileageRow[];
sources: MileageExportSource[];
exportedAt?: Date;
};
function excelColumn(index: number) {
let value = index;
let result = '';
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + value % 26) + result;
value = Math.floor(value / 26);
}
return result;
}
function localDateTime(value: Date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(value).split('/').join('-');
}
export async function createMileageWorkbook(input: MileageExportInput) {
const { Workbook } = await import('exceljs');
const workbook = new Workbook();
workbook.creator = '灵牛车辆数据中台';
workbook.company = '灵牛科技';
workbook.created = input.exportedAt ?? new Date();
workbook.modified = input.exportedAt ?? new Date();
workbook.calcProperties.fullCalcOnLoad = true;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 2, ySplit: 6, activeCell: 'C7', showGridLines: false }],
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
properties: { defaultRowHeight: 21 }
});
const firstDateColumn = 3;
const lastDateColumn = firstDateColumn + input.dates.length - 1;
const totalColumn = lastDateColumn + 1;
const lastColumnLetter = excelColumn(totalColumn);
const headerRowNumber = 6;
const firstDataRow = headerRowNumber + 1;
const lastDataRow = firstDataRow + input.vehicles.length - 1;
const exportedAt = input.exportedAt ?? new Date();
const sourceDescription = input.sources.map((source, index) => `${index + 1}. ${source.label}${source.mileageType}`).join(' ');
const title = sheet.getCell('A1');
title.value = '车辆里程查询明细';
title.font = { name: 'Microsoft YaHei', size: 18, bold: true, color: { argb: 'FF17345C' } };
title.alignment = { vertical: 'middle', horizontal: 'left' };
title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
for (let column = 1; column <= totalColumn; column += 1) {
const cell = sheet.getCell(1, column);
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8F1FC' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFBDD0E8' } } };
}
sheet.getRow(1).height = 38;
sheet.getRow(2).values = ['日期范围', `${input.dateFrom}${input.dateTo}`, '车辆范围', `${input.vehicles.length}`];
sheet.getRow(3).values = ['来源优先级', sourceDescription];
const periodTotal = input.mileageRows.reduce((sum, row) => sum + (Number.isFinite(row.dailyMileageKm) ? row.dailyMileageKm : 0), 0);
sheet.getRow(4).values = ['区间总里程', null, '有效车辆日', `${input.mileageRows.length}`];
sheet.getCell('B4').value = input.vehicles.length ? { formula: `SUM(${lastColumnLetter}${firstDataRow}:${lastColumnLetter}${lastDataRow})`, result: periodTotal } : 0;
sheet.getCell('B4').numFmt = '#,##0.0" km"';
for (let rowNumber = 2; rowNumber <= 4; rowNumber += 1) {
const row = sheet.getRow(rowNumber);
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: 'Microsoft YaHei', size: 10, bold: columnNumber === 1 || columnNumber === 3, color: { argb: columnNumber === 1 || columnNumber === 3 ? 'FF53657D' : 'FF213149' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber === 1 || columnNumber === 3 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: rowNumber % 2 ? 'FFF8FAFD' : 'FFF2F6FB' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE1E8F1' } } };
});
}
sheet.getRow(5).height = 8;
const header = sheet.getRow(headerRowNumber);
header.values = ['车牌', 'VIN', ...input.dates.map((date) => new Date(`${date}T12:00:00Z`)), '区间总里程'];
header.height = 30;
header.eachCell((cell, columnNumber) => {
const isDateHeader = columnNumber >= firstDateColumn && columnNumber <= lastDateColumn;
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: columnNumber === totalColumn ? 'FF1C4F91' : 'FF304158' } };
cell.alignment = {
vertical: 'middle',
horizontal: columnNumber <= 2 ? 'left' : isDateHeader ? 'center' : 'right'
};
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: columnNumber === totalColumn ? 'FFDCEBFF' : 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
if (isDateHeader) cell.numFmt = 'm/d';
});
const rowsByVin = new Map<string, Map<string, DailyMileageRow>>();
for (const row of input.mileageRows) {
if (!rowsByVin.has(row.vin)) rowsByVin.set(row.vin, new Map());
rowsByVin.get(row.vin)!.set(row.date, row);
}
input.vehicles.forEach((vehicle, index) => {
const rowNumber = firstDataRow + index;
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, DailyMileageRow>();
const dailyValues = input.dates.map((date) => mileageByDate.get(date)?.dailyMileageKm ?? null);
const total = dailyValues.reduce<number>((sum, value) => sum + (value ?? 0), 0);
const row = sheet.getRow(rowNumber);
row.values = [vehicle.plate || '未绑定', vehicle.vin, ...dailyValues, null];
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 2 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 2 ? 9 : 10, color: { argb: columnNumber === totalColumn ? 'FF1D4E89' : 'FF34445A' }, bold: columnNumber === 1 || columnNumber === totalColumn };
cell.alignment = { vertical: 'middle', horizontal: columnNumber <= 2 ? 'left' : 'right' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if (columnNumber >= firstDateColumn) cell.numFmt = '#,##0.0" km"';
});
const dateStart = excelColumn(firstDateColumn);
const dateEnd = excelColumn(lastDateColumn);
const totalCell = sheet.getCell(rowNumber, totalColumn);
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
totalCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFEDF5FF' : 'FFF4F8FE' } };
row.commit();
});
sheet.getColumn(1).width = 15;
sheet.getColumn(2).width = 24;
for (let column = firstDateColumn; column <= lastDateColumn; column += 1) sheet.getColumn(column).width = 12;
sheet.getColumn(totalColumn).width = 17;
if (input.vehicles.length) {
sheet.autoFilter = { from: { row: headerRowNumber, column: 1 }, to: { row: lastDataRow, column: totalColumn } };
sheet.addConditionalFormatting({
ref: `${excelColumn(firstDateColumn)}${firstDataRow}:${excelColumn(lastDateColumn)}${lastDataRow}`,
rules: [{
type: 'colorScale', priority: 1,
cfvo: [{ type: 'min' }, { type: 'percentile', value: 50 }, { type: 'max' }],
color: [{ argb: 'FFFFFFFF' }, { argb: 'FFE8F2FF' }, { argb: 'FFB9D8FF' }]
}]
});
}
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
return workbook;
}
export async function downloadMileageWorkbook(input: MileageExportInput) {
const workbook = await createMileageWorkbook(input);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${input.vehicles.length}辆.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
}