Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/domain/mileageWorkbook.ts
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

210 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MileageExportInput, MileageWorkbookRow } from './mileageExport';
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;
// Create the selected view first so Excel/WPS opens the requested export.
const primaryHydrogenSheet = input.metricView === 'hydrogen' ? workbook.addWorksheet('氢能明细') : undefined;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', 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 = 5;
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 <= 4 ? '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, MileageWorkbookRow>>();
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);
}
// ExcelJS retains every cell until ZIP serialization. Share immutable style
// objects instead of allocating fonts, fills and borders per vehicle-day.
const bodyStyles = [0, 1].map((parity) => Array.from({ length: 5 }, (_, kind) => ({
font: { name: kind === 1 ? 'Consolas' : 'Microsoft YaHei', size: kind === 1 ? 9 : 10, color: { argb: kind === 3 ? 'FF1D4E89' : 'FF34445A' }, bold: kind === 0 || kind === 3 },
alignment: { vertical: 'middle' as const, horizontal: kind <= 1 || kind === 4 ? 'left' as const : 'right' as const },
fill: { type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: kind === 3 ? (parity ? 'FFEDF5FF' : 'FFF4F8FE') : (parity ? 'FFF9FBFD' : 'FFFFFFFF') } },
border: { bottom: { style: 'thin' as const, color: { argb: 'FFE6ECF3' } } },
...((kind === 2 || kind === 3) ? { numFmt: '#,##0.0" km"' } : {})
})));
input.vehicles.forEach((vehicle, index) => {
const rowNumber = firstDataRow + index;
const mileageByDate = rowsByVin.get(vehicle.vin) ?? new Map<string, MileageWorkbookRow>();
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, vehicle.brandName || '待维护', vehicle.modelName || '待维护', ...dailyValues, null];
row.height = 25;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
const kind = columnNumber === totalColumn ? 3 : columnNumber >= firstDateColumn ? 2 : columnNumber === 2 ? 1 : columnNumber === 1 ? 0 : 4;
cell.style = bodyStyles[index % 2][kind];
});
const dateStart = excelColumn(firstDateColumn);
const dateEnd = excelColumn(lastDateColumn);
const totalCell = sheet.getCell(rowNumber, totalColumn);
totalCell.value = { formula: `SUM(${dateStart}${rowNumber}:${dateEnd}${rowNumber})`, result: total };
row.commit();
});
sheet.getColumn(1).width = 15;
sheet.getColumn(2).width = 24;
sheet.getColumn(3).width = 18;
sheet.getColumn(4).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);
// A GPS annual report must not allocate a 24-column energy row for every
// vehicle-day (300,000 days otherwise become 7.2 million extra cells).
if (input.metricView !== 'hydrogen') return workbook;
const hydrogenSheet = primaryHydrogenSheet!;
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
hydrogenSheet.properties.defaultRowHeight = 21;
const hydrogenHeaders = [
'日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
];
hydrogenSheet.addRow(hydrogenHeaders);
const hydrogenHeader = hydrogenSheet.getRow(1);
hydrogenHeader.height = 30;
hydrogenHeader.eachCell((cell) => {
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: 'FF304158' } };
cell.alignment = { vertical: 'middle', horizontal: 'center' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
});
const vehicleByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle]));
const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
energyRows.forEach((value, index) => {
const vehicle = vehicleByVIN.get(value.vin);
const row = hydrogenSheet.addRow([
value.date,
vehicle?.plate || value.plate || '未绑定',
value.province || '未知', value.city || '未知', value.region || '未知',
value.vin,
vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护',
value.hydrogenConsumptionKgPer100Km ?? null,
value.hydrogenQualityStatus || 'NO_DATA',
value.dailyMileageKm,
value.pureElectricMileageKm ?? null,
value.mixedMileageKm ?? null,
value.batterySocDeltaPct ?? null,
value.chargeCount ?? null,
value.chargeEnergyKWh ?? null,
value.refuelCount ?? null,
value.refuelAmountKg ?? null,
value.hydrogenSocBalancedKg ?? null,
value.hydrogenQualityReason || '',
value.hydrogenPhysicalConsumptionKg ?? null,
value.source ?? '',
value.locationTime || '', value.locationStatus || '未查询'
]);
row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 6 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 6 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 9 && columnNumber <= 19 ? 'right' : 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if ([9, 11, 12, 13, 14, 16, 18, 19, 21].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([15, 17].includes(columnNumber)) cell.numFmt = '0';
});
});
[13, 15, 18, 18, 12, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16, 30, 24].forEach((width, index) => {
hydrogenSheet.getColumn(index + 1).width = width;
});
if (energyRows.length) {
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: hydrogenHeaders.length } };
}
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
return workbook;
}