Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/domain/mileageExport.ts
T

142 lines
5.4 KiB
TypeScript

import type { DailyMileageRow } from '../../api/types';
import { downloadBlob } from './download';
export type MileageExportVehicle = { vin: string; plate: string; brandName?: string; modelName?: string };
export type MileageExportSource = { protocol: string; label: string; mileageType: string };
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
export type MileageExportInput = {
metricView?: 'mileage' | 'hydrogen';
dateFrom: string;
dateTo: string;
dates: string[];
vehicles: MileageExportVehicle[];
mileageRows: MileageWorkbookRow[];
sources: MileageExportSource[];
exportedAt?: Date;
};
type MileageWorkerResponse = { ok: true; buffer: ArrayBuffer } | { ok: false; message: string };
type MileageWorkerRequest =
| { type: 'start'; input: Omit<MileageExportInput, 'vehicles' | 'mileageRows'> }
| { type: 'rows'; rows: MileageWorkbookRow[] }
| { type: 'finish'; vehicles: MileageExportVehicle[] };
function abortError() {
return new DOMException('里程导出已取消', 'AbortError');
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
export type MileageExportStream = {
appendRows: (rows: readonly MileageWorkbookRow[]) => void;
finish: (vehicles: MileageExportVehicle[]) => Promise<void>;
dispose: () => void;
};
export function createMileageExportStream(input: Omit<MileageExportInput, 'vehicles' | 'mileageRows'>, signal?: AbortSignal): MileageExportStream {
throwIfAborted(signal);
if (typeof Worker === 'undefined') {
throw new Error('当前浏览器不支持后台 Excel 导出,请升级到最新版 Chrome、Edge 或 Safari 后重试');
}
const worker = new Worker(new URL('./mileageExport.worker.ts', import.meta.url), { type: 'module' });
let disposed = false;
let finishing = false;
let resolveFinish: (() => void) | undefined;
let rejectFinish: ((reason?: unknown) => void) | undefined;
let earlyError: Error | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
signal?.removeEventListener('abort', cancel);
worker.terminate();
};
const reject = (error: Error) => {
if (disposed) return;
if (rejectFinish) rejectFinish(error);
else earlyError = error;
dispose();
};
const cancel = () => reject(abortError());
worker.onmessage = (event: MessageEvent<MileageWorkerResponse>) => {
if (disposed) return;
if (!event.data.ok) {
reject(new Error(event.data.message));
return;
}
const buffer = event.data.buffer;
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
resolveFinish?.();
dispose();
};
worker.onerror = (event) => reject(new Error(event.message || 'Excel 工作线程执行失败'));
signal?.addEventListener('abort', cancel, { once: true });
let finishVehicles: MileageExportVehicle[] = [];
const post = (message: MileageWorkerRequest) => worker.postMessage(message);
post({ type: 'start', input });
return {
appendRows(rows) {
if (disposed || finishing) return;
post({
type: 'rows',
rows: rows.map((row) => ({
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
vin: row.vin,
date: row.date,
dailyMileageKm: row.dailyMileageKm,
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
hydrogenPhysicalConsumptionKg: row.hydrogenPhysicalConsumptionKg,
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
pureElectricMileageKm: row.pureElectricMileageKm,
mixedMileageKm: row.mixedMileageKm,
batterySocDeltaPct: row.batterySocDeltaPct,
chargeCount: row.chargeCount,
chargeEnergyKWh: row.chargeEnergyKWh,
refuelCount: row.refuelCount,
refuelAmountKg: row.refuelAmountKg,
hydrogenQualityStatus: row.hydrogenQualityStatus,
hydrogenQualityReason: row.hydrogenQualityReason,
source: row.source
}))
});
},
finish(vehicles) {
if (disposed) return Promise.reject(earlyError ?? abortError());
if (finishing) return Promise.reject(new Error('里程导出已经进入生成阶段'));
finishing = true;
finishVehicles = vehicles;
return new Promise<void>((resolve, rejectPromise) => {
resolveFinish = resolve;
rejectFinish = rejectPromise;
if (earlyError) {
rejectPromise(earlyError);
dispose();
return;
}
post({ type: 'finish', vehicles });
});
},
dispose
};
}
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
const stream = createMileageExportStream({
metricView: input.metricView,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
dates: input.dates,
sources: input.sources,
exportedAt: input.exportedAt
}, signal);
stream.appendRows(input.mileageRows);
await stream.finish(input.vehicles);
}