742 lines
25 KiB
JavaScript
742 lines
25 KiB
JavaScript
import fs from "node:fs/promises";
|
||
import fsSync from "node:fs";
|
||
import path from "node:path";
|
||
import { once } from "node:events";
|
||
import {
|
||
FileBlob,
|
||
SpreadsheetFile,
|
||
Workbook,
|
||
} from "@oai/artifact-tool";
|
||
|
||
const root =
|
||
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
|
||
const workDir = path.join(root, "outputs/g7-mileage-history-20260724");
|
||
const dailyDir = path.join(workDir, "raw/daily");
|
||
const statisticDir = path.join(workDir, "raw/statistic");
|
||
const mergedDir = path.join(workDir, "merged");
|
||
const outputXlsx = path.join(
|
||
mergedDir,
|
||
"G7车辆每日里程及里程统计_20220101-20260720.xlsx",
|
||
);
|
||
const importCsv = path.join(
|
||
mergedDir,
|
||
"G7车辆每日里程_20220101-20260720.csv",
|
||
);
|
||
const manifestPath = path.join(mergedDir, "manifest.json");
|
||
const priorMappingCsv = path.join(
|
||
root,
|
||
"outputs/g7-mileage-import-20260716/import_rows.csv",
|
||
);
|
||
|
||
await fs.mkdir(mergedDir, { recursive: true });
|
||
|
||
const months = buildMonths();
|
||
const dailyFilesByTaskId = await sortedXlsxFiles(dailyDir);
|
||
// 2022-02 was the one-file validation task. The remaining formal queue then
|
||
// started with 2022-01 and continued from 2022-03, so swap the first two task IDs
|
||
// back into natural month order.
|
||
const dailyFiles = [
|
||
dailyFilesByTaskId[1],
|
||
dailyFilesByTaskId[0],
|
||
...dailyFilesByTaskId.slice(2),
|
||
];
|
||
const statisticFiles = await sortedXlsxFiles(statisticDir);
|
||
assert(dailyFiles.length === months.length, `日报文件应为 ${months.length},实际 ${dailyFiles.length}`);
|
||
assert(
|
||
statisticFiles.length === months.length,
|
||
`里程统计文件应为 ${months.length},实际 ${statisticFiles.length}`,
|
||
);
|
||
|
||
const vehicles = new Map();
|
||
const dateOrder = [];
|
||
const dateSource = new Map();
|
||
const monthlyDailyTotals = new Map();
|
||
const dailyBatchManifest = [];
|
||
let dailyPositiveCells = 0;
|
||
let dailyZeroCells = 0;
|
||
let dailyTotalKm = 0;
|
||
let dailyTotalMismatchRows = 0;
|
||
let dailyCompletenessNoteRows = 0;
|
||
|
||
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
|
||
const month = months[monthIndex];
|
||
const file = dailyFiles[monthIndex];
|
||
const values = await readFirstSheetValues(file);
|
||
const headers = values[0] ?? [];
|
||
const runtimeIndex = headers.indexOf("运行时长");
|
||
assert(runtimeIndex > 3, `${path.basename(file)} 缺少运行时长列`);
|
||
const dateColumnCount = runtimeIndex - 3;
|
||
assert(
|
||
dateColumnCount === month.dayCount,
|
||
`${path.basename(file)} 日期列应为 ${month.dayCount},实际 ${dateColumnCount}`,
|
||
);
|
||
|
||
const monthDates = [];
|
||
for (let day = 1; day <= month.dayCount; day += 1) {
|
||
const date = `${month.year}-${pad(month.month)}-${pad(day)}`;
|
||
monthDates.push(date);
|
||
dateOrder.push(date);
|
||
dateSource.set(date, path.basename(file));
|
||
}
|
||
|
||
let batchTotal = 0;
|
||
let batchPositive = 0;
|
||
let batchZero = 0;
|
||
let batchMismatch = 0;
|
||
let batchNotes = 0;
|
||
const seenPlates = new Set();
|
||
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
|
||
const row = values[rowIndex] ?? [];
|
||
const plate = cleanText(row[0]);
|
||
if (!plate) continue;
|
||
assert(!seenPlates.has(plate), `${path.basename(file)} 存在重复车牌 ${plate}`);
|
||
seenPlates.add(plate);
|
||
const organization = cleanText(row[1]);
|
||
const statedTotal = numberValue(row[2]);
|
||
const dailyValues = row.slice(3, runtimeIndex).map(numberValue);
|
||
const calculatedTotal = dailyValues.reduce((sum, value) => sum + value, 0);
|
||
if (Math.abs(statedTotal - calculatedTotal) > 0.011) {
|
||
dailyTotalMismatchRows += 1;
|
||
batchMismatch += 1;
|
||
}
|
||
const note = cleanText(row[runtimeIndex + 1]);
|
||
if (note) {
|
||
dailyCompletenessNoteRows += 1;
|
||
batchNotes += 1;
|
||
}
|
||
|
||
let vehicle = vehicles.get(plate);
|
||
if (!vehicle) {
|
||
vehicle = {
|
||
plate,
|
||
organization,
|
||
values: new Map(),
|
||
};
|
||
vehicles.set(plate, vehicle);
|
||
} else if (!vehicle.organization && organization) {
|
||
vehicle.organization = organization;
|
||
}
|
||
|
||
for (let dayIndex = 0; dayIndex < dailyValues.length; dayIndex += 1) {
|
||
const value = dailyValues[dayIndex];
|
||
const date = monthDates[dayIndex];
|
||
vehicle.values.set(date, value);
|
||
batchTotal += value;
|
||
dailyTotalKm += value;
|
||
if (value > 0) {
|
||
batchPositive += 1;
|
||
dailyPositiveCells += 1;
|
||
} else {
|
||
batchZero += 1;
|
||
dailyZeroCells += 1;
|
||
}
|
||
}
|
||
}
|
||
monthlyDailyTotals.set(month.key, round(batchTotal));
|
||
dailyBatchManifest.push({
|
||
type: "车辆里程日报",
|
||
month: month.key,
|
||
date_from: month.dateFrom,
|
||
date_to: month.dateTo,
|
||
file: path.basename(file),
|
||
vehicle_rows: seenPlates.size,
|
||
date_columns: month.dayCount,
|
||
positive_cells: batchPositive,
|
||
zero_cells: batchZero,
|
||
total_km: round(batchTotal),
|
||
total_mismatch_rows: batchMismatch,
|
||
completeness_note_rows: batchNotes,
|
||
});
|
||
}
|
||
|
||
assert(dateOrder.length === 1662, `日期列应为 1662,实际 ${dateOrder.length}`);
|
||
assert(new Set(dateOrder).size === dateOrder.length, "日报日期列存在重复");
|
||
assert(dateOrder[0] === "2022-01-01", `起始日期异常:${dateOrder[0]}`);
|
||
assert(dateOrder.at(-1) === "2026-07-20", `结束日期异常:${dateOrder.at(-1)}`);
|
||
|
||
const statisticRows = [];
|
||
const monthlyStatisticTotals = new Map();
|
||
const statisticBatchManifest = [];
|
||
for (let monthIndex = 0; monthIndex < months.length; monthIndex += 1) {
|
||
const month = months[monthIndex];
|
||
const file = statisticFiles[monthIndex];
|
||
const values = await readFirstSheetValues(file);
|
||
const headers = (values[0] ?? []).map(cleanText);
|
||
const plateIndex = findHeader(headers, ["车牌号"]);
|
||
const orgIndex = findHeader(headers, ["所属机构", "机构"]);
|
||
const sourceOrgIndex = findHeader(headers, ["来源机构"]);
|
||
const lengthIndex = findHeader(headers, ["车长(米)", "车长"]);
|
||
const boxIndex = findHeader(headers, ["厢型"]);
|
||
const runtimeIndex = findHeader(headers, ["运行时长"]);
|
||
const mileageIndex = findHeader(headers, ["行驶里程(KM)", "行驶里程"]);
|
||
assert(plateIndex >= 0, `${path.basename(file)} 缺少车牌号`);
|
||
assert(mileageIndex >= 0, `${path.basename(file)} 缺少行驶里程`);
|
||
|
||
let batchTotal = 0;
|
||
let vehicleRows = 0;
|
||
for (let rowIndex = 1; rowIndex < values.length; rowIndex += 1) {
|
||
const row = values[rowIndex] ?? [];
|
||
const plate = cleanText(row[plateIndex]);
|
||
if (!plate) continue;
|
||
const mileage = numberValue(row[mileageIndex]);
|
||
batchTotal += mileage;
|
||
vehicleRows += 1;
|
||
statisticRows.push([
|
||
month.key,
|
||
plate,
|
||
orgIndex >= 0 ? cleanText(row[orgIndex]) : "",
|
||
sourceOrgIndex >= 0 ? cleanText(row[sourceOrgIndex]) : "",
|
||
lengthIndex >= 0 ? row[lengthIndex] ?? "" : "",
|
||
boxIndex >= 0 ? cleanText(row[boxIndex]) : "",
|
||
runtimeIndex >= 0 ? cleanText(row[runtimeIndex]) : "",
|
||
mileage,
|
||
path.basename(file),
|
||
]);
|
||
}
|
||
monthlyStatisticTotals.set(month.key, round(batchTotal));
|
||
statisticBatchManifest.push({
|
||
type: "车辆里程统计",
|
||
month: month.key,
|
||
date_from: month.dateFrom,
|
||
date_to: month.dateTo,
|
||
file: path.basename(file),
|
||
vehicle_rows: vehicleRows,
|
||
total_km: round(batchTotal),
|
||
});
|
||
}
|
||
|
||
const mapping = await readPriorMapping(priorMappingCsv);
|
||
const sortedVehicles = [...vehicles.values()].sort((a, b) =>
|
||
a.plate.localeCompare(b.plate, "zh-CN"),
|
||
);
|
||
const mappedVehicles = sortedVehicles.filter((vehicle) => mapping.has(vehicle.plate));
|
||
const unmappedVehicles = sortedVehicles.filter((vehicle) => !mapping.has(vehicle.plate));
|
||
|
||
await writeImportCsv(importCsv, sortedVehicles, dateOrder);
|
||
|
||
const workbook = Workbook.create();
|
||
const summarySheet = workbook.worksheets.add("汇总");
|
||
const positiveDailySheet = workbook.worksheets.add("每日里程非零");
|
||
const statisticSheet = workbook.worksheets.add("G7里程统计");
|
||
const mappingSheet = workbook.worksheets.add("车辆映射");
|
||
const batchSheet = workbook.worksheets.add("导出批次");
|
||
|
||
buildSummarySheet(summarySheet);
|
||
await buildPositiveDailySheet(positiveDailySheet);
|
||
await buildStatisticSheet(statisticSheet);
|
||
await buildMappingSheet(mappingSheet);
|
||
await buildBatchSheet(batchSheet);
|
||
|
||
const summaryInspect = await workbook.inspect({
|
||
kind: "table",
|
||
sheetId: "汇总",
|
||
range: "A1:F25",
|
||
include: "values,formulas",
|
||
maxChars: 12000,
|
||
tableMaxRows: 25,
|
||
tableMaxCols: 6,
|
||
});
|
||
const dailyInspect = await workbook.inspect({
|
||
kind: "table",
|
||
sheetId: "每日里程非零",
|
||
range: "A1:D12",
|
||
include: "values,formulas",
|
||
maxChars: 12000,
|
||
tableMaxRows: 12,
|
||
tableMaxCols: 4,
|
||
});
|
||
const formulaErrors = await workbook.inspect({
|
||
kind: "match",
|
||
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
|
||
options: { useRegex: true, maxResults: 300 },
|
||
summary: "final formula error scan",
|
||
});
|
||
assert(!formulaErrors.ndjson.includes('"match"'), "工作簿存在公式错误");
|
||
|
||
const summaryPreview = await workbook.render({
|
||
sheetName: "汇总",
|
||
range: "A1:F25",
|
||
scale: 1.5,
|
||
format: "png",
|
||
});
|
||
await fs.writeFile(
|
||
path.join(mergedDir, "summary-preview.png"),
|
||
new Uint8Array(await summaryPreview.arrayBuffer()),
|
||
);
|
||
const dailyPreview = await workbook.render({
|
||
sheetName: "每日里程非零",
|
||
range: "A1:D12",
|
||
scale: 1.2,
|
||
format: "png",
|
||
});
|
||
await fs.writeFile(
|
||
path.join(mergedDir, "daily-preview.png"),
|
||
new Uint8Array(await dailyPreview.arrayBuffer()),
|
||
);
|
||
|
||
try {
|
||
const output = await SpreadsheetFile.exportXlsx(workbook);
|
||
await output.save(outputXlsx);
|
||
} catch (error) {
|
||
console.error(
|
||
JSON.stringify(
|
||
{
|
||
stage: "export_xlsx",
|
||
name: error?.name,
|
||
message: error?.message,
|
||
stack: String(error?.stack ?? "")
|
||
.split("\n")
|
||
.filter((line) => !line.includes("artifact_tool.mjs:3121"))
|
||
.slice(0, 12),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const monthlyReconciliation = months.map((month) => {
|
||
const dailyKm = monthlyDailyTotals.get(month.key) ?? 0;
|
||
const statisticKm = monthlyStatisticTotals.get(month.key) ?? 0;
|
||
return {
|
||
month: month.key,
|
||
daily_km: dailyKm,
|
||
statistic_km: statisticKm,
|
||
difference_km: round(dailyKm - statisticKm),
|
||
};
|
||
});
|
||
const manifest = {
|
||
generated_at: new Date().toISOString(),
|
||
range: {
|
||
date_from: dateOrder[0],
|
||
date_to: dateOrder.at(-1),
|
||
days: dateOrder.length,
|
||
months: months.length,
|
||
},
|
||
files: {
|
||
daily_raw: dailyFiles.map((file) => path.basename(file)),
|
||
statistic_raw: statisticFiles.map((file) => path.basename(file)),
|
||
workbook: path.basename(outputXlsx),
|
||
import_csv: path.basename(importCsv),
|
||
},
|
||
vehicle_count: sortedVehicles.length,
|
||
mapped_vehicle_count: mappedVehicles.length,
|
||
unmapped_vehicle_count: unmappedVehicles.length,
|
||
vehicle_day_rows: sortedVehicles.length * dateOrder.length,
|
||
mapped_vehicle_day_rows: mappedVehicles.length * dateOrder.length,
|
||
positive_cells: dailyPositiveCells,
|
||
zero_cells: dailyZeroCells,
|
||
daily_total_km: round(dailyTotalKm),
|
||
daily_total_mismatch_rows: dailyTotalMismatchRows,
|
||
completeness_note_rows: dailyCompletenessNoteRows,
|
||
monthly_reconciliation: monthlyReconciliation,
|
||
daily_batches: dailyBatchManifest,
|
||
statistic_batches: statisticBatchManifest,
|
||
verification: {
|
||
summary_inspect: summaryInspect.ndjson,
|
||
daily_inspect: dailyInspect.ndjson,
|
||
formula_errors: formulaErrors.ndjson,
|
||
},
|
||
};
|
||
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
outputXlsx,
|
||
importCsv,
|
||
manifestPath,
|
||
vehicleCount: sortedVehicles.length,
|
||
mappedVehicleCount: mappedVehicles.length,
|
||
unmappedVehicleCount: unmappedVehicles.length,
|
||
dateCount: dateOrder.length,
|
||
vehicleDayRows: sortedVehicles.length * dateOrder.length,
|
||
mappedVehicleDayRows: mappedVehicles.length * dateOrder.length,
|
||
positiveCells: dailyPositiveCells,
|
||
zeroCells: dailyZeroCells,
|
||
totalKm: round(dailyTotalKm),
|
||
monthlyDifferenceCount: monthlyReconciliation.filter(
|
||
(row) => Math.abs(row.difference_km) > 0.02,
|
||
).length,
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
|
||
function buildSummarySheet(sheet) {
|
||
sheet.showGridLines = false;
|
||
sheet.getRange("A1:F1").merge();
|
||
sheet.getRange("A1").values = [["G7 车辆里程全量导出汇总"]];
|
||
sheet.getRange("A1:F1").format = {
|
||
fill: "#0F766E",
|
||
font: { bold: true, color: "#FFFFFF", size: 16 },
|
||
horizontalAlignment: "center",
|
||
verticalAlignment: "center",
|
||
};
|
||
sheet.getRange("A1:F1").format.rowHeight = 30;
|
||
|
||
const metrics = [
|
||
["指标", "值", "说明"],
|
||
["统计起始日期", new Date(`${dateOrder[0]}T00:00:00+08:00`), "G7 日报"],
|
||
["统计结束日期", new Date(`${dateOrder.at(-1)}T00:00:00+08:00`), "含当日"],
|
||
["自然日数", dateOrder.length, "连续日期"],
|
||
["日报导出批次", dailyFiles.length, "每月 1 份"],
|
||
["里程统计导出批次", statisticFiles.length, "每月 1 份"],
|
||
["G7 车辆数", sortedVehicles.length, "当前导出车辆列表"],
|
||
["已映射 VIN 车辆数", mappedVehicles.length, "沿用现有车辆映射"],
|
||
["未映射车辆数", unmappedVehicles.length, "不进入正式导入"],
|
||
["映射覆盖率", null, "已映射 / G7 车辆数"],
|
||
["车辆-日期单元格", sortedVehicles.length * dateOrder.length, "包含 0 km"],
|
||
["可导入车辆-日期行", mappedVehicles.length * dateOrder.length, "包含 0 km"],
|
||
["正里程单元格", dailyPositiveCells, "> 0 km"],
|
||
["0 km 单元格", dailyZeroCells, "按用户确认保留"],
|
||
["日报累计里程(km)", round(dailyTotalKm), "55 份日报求和"],
|
||
["日报合计不一致车辆月", dailyTotalMismatchRows, "日报“行驶里程”与逐日求和"],
|
||
["完整度说明非空车辆月", dailyCompletenessNoteRows, "来自 G7 日报"],
|
||
];
|
||
sheet.getRangeByIndexes(2, 0, metrics.length, 3).values = metrics;
|
||
sheet.getRange("A3:C3").format = headerFormat();
|
||
sheet.getRange("B4:B5").format.numberFormat = "yyyy-mm-dd";
|
||
sheet.getRange("B6:B19").format.numberFormat = "#,##0.00";
|
||
sheet.getRange("B12").formulas = [["=IFERROR(B10/B9,0)"]];
|
||
sheet.getRange("B12").format.numberFormat = "0.0%";
|
||
sheet.getRange("A4:A19").format.font = { bold: true, color: "#134E4A" };
|
||
sheet.getRange("A3:C19").format.borders = {
|
||
preset: "inside",
|
||
style: "thin",
|
||
color: "#D1D5DB",
|
||
};
|
||
|
||
sheet.getRange("E3:H3").values = [["月份", "日报合计(km)", "里程统计合计(km)", "差异(km)"]];
|
||
sheet.getRange("E3:H3").format = headerFormat();
|
||
const monthlyRows = months.map((month) => [
|
||
month.key,
|
||
monthlyDailyTotals.get(month.key) ?? 0,
|
||
monthlyStatisticTotals.get(month.key) ?? 0,
|
||
null,
|
||
]);
|
||
sheet.getRangeByIndexes(3, 4, monthlyRows.length, 4).values = monthlyRows;
|
||
sheet.getRange("H4").formulas = [["=F4-G4"]];
|
||
sheet.getRange(`H4:H${3 + monthlyRows.length}`).fillDown();
|
||
sheet.getRange(`F4:H${3 + monthlyRows.length}`).format.numberFormat = "#,##0.00";
|
||
sheet.getRange(`E3:H${3 + monthlyRows.length}`).format.borders = {
|
||
preset: "inside",
|
||
style: "thin",
|
||
color: "#E5E7EB",
|
||
};
|
||
|
||
sheet.freezePanes.freezeRows(3);
|
||
sheet.getRange("A21:C23").merge();
|
||
sheet.getRange("A21").values = [[
|
||
"Excel 的“每日里程非零”工作表列出全部正里程记录;未出现的车辆×日期组合按用户确认均为 0 km。含全部 0 km 的逐行明细保存在同目录 CSV:G7车辆每日里程_20220101-20260720.csv。",
|
||
]];
|
||
sheet.getRange("A21:C23").format = {
|
||
fill: "#ECFDF5",
|
||
font: { color: "#065F46", italic: true },
|
||
wrapText: true,
|
||
verticalAlignment: "center",
|
||
};
|
||
sheet.getRange(`A1:H${3 + monthlyRows.length}`).format.font = { name: "Aptos", size: 10 };
|
||
sheet.getRange(`A1:A${3 + monthlyRows.length}`).format.columnWidth = 24;
|
||
sheet.getRange(`B1:B${3 + monthlyRows.length}`).format.columnWidth = 18;
|
||
sheet.getRange(`C1:C${3 + monthlyRows.length}`).format.columnWidth = 30;
|
||
sheet.getRange(`D1:D${3 + monthlyRows.length}`).format.columnWidth = 3;
|
||
sheet.getRange(`E1:E${3 + monthlyRows.length}`).format.columnWidth = 12;
|
||
sheet.getRange(`F1:H${3 + monthlyRows.length}`).format.columnWidth = 18;
|
||
}
|
||
|
||
async function buildPositiveDailySheet(sheet) {
|
||
sheet.showGridLines = false;
|
||
const headers = ["日期", "车牌号", "机构", "每日里程(km)"];
|
||
sheet.getRange("A1:D1").values = [headers];
|
||
sheet.getRange("A1:D1").format = headerFormat();
|
||
const rows = [];
|
||
for (const vehicle of sortedVehicles) {
|
||
for (const date of dateOrder) {
|
||
const mileage = vehicle.values.get(date) ?? 0;
|
||
if (mileage <= 0) continue;
|
||
rows.push([
|
||
new Date(`${date}T00:00:00+08:00`),
|
||
vehicle.plate,
|
||
vehicle.organization,
|
||
mileage,
|
||
]);
|
||
}
|
||
}
|
||
const chunkSize = 1000;
|
||
for (let start = 0; start < rows.length; start += chunkSize) {
|
||
const chunk = rows.slice(start, start + chunkSize);
|
||
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
|
||
}
|
||
sheet.getRange(`A2:A${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
|
||
sheet.getRange(`D2:D${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||
sheet.freezePanes.freezeRows(1);
|
||
[14, 16, 30, 18].forEach((width, index) => {
|
||
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||
});
|
||
}
|
||
|
||
async function buildStatisticSheet(sheet) {
|
||
sheet.showGridLines = false;
|
||
const headers = [
|
||
"月份",
|
||
"车牌号",
|
||
"所属机构",
|
||
"来源机构",
|
||
"车长(米)",
|
||
"厢型",
|
||
"运行时长",
|
||
"行驶里程(KM)",
|
||
"来源文件",
|
||
];
|
||
sheet.getRange("A1:I1").values = [headers];
|
||
sheet.getRange("A1:I1").format = headerFormat();
|
||
const chunkSize = 1000;
|
||
for (let start = 0; start < statisticRows.length; start += chunkSize) {
|
||
const chunk = statisticRows.slice(start, start + chunkSize);
|
||
sheet.getRangeByIndexes(start + 1, 0, chunk.length, headers.length).values = chunk;
|
||
}
|
||
sheet.getRange(`E2:E${statisticRows.length + 1}`).format.numberFormat = "0.0";
|
||
sheet.getRange(`H2:H${statisticRows.length + 1}`).format.numberFormat = "#,##0.00";
|
||
sheet.freezePanes.freezeRows(1);
|
||
const widths = [12, 16, 28, 28, 12, 16, 18, 18, 34];
|
||
widths.forEach((width, index) => {
|
||
sheet.getRangeByIndexes(0, index, statisticRows.length + 1, 1).format.columnWidth = width;
|
||
});
|
||
}
|
||
|
||
async function buildMappingSheet(sheet) {
|
||
sheet.showGridLines = false;
|
||
const headers = ["车牌号", "机构", "VIN", "终端号", "映射状态", "期间里程(km)", "正里程天数", "0 km天数"];
|
||
sheet.getRange("A1:H1").values = [headers];
|
||
sheet.getRange("A1:H1").format = headerFormat();
|
||
const rows = sortedVehicles.map((vehicle) => {
|
||
const mapped = mapping.get(vehicle.plate);
|
||
const allValues = dateOrder.map((date) => vehicle.values.get(date) ?? 0);
|
||
const positiveDays = allValues.filter((value) => value > 0).length;
|
||
return [
|
||
vehicle.plate,
|
||
vehicle.organization,
|
||
mapped?.vin ?? "",
|
||
mapped?.phone ?? "",
|
||
mapped ? "已映射" : "未映射",
|
||
round(allValues.reduce((sum, value) => sum + value, 0)),
|
||
positiveDays,
|
||
dateOrder.length - positiveDays,
|
||
];
|
||
});
|
||
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
|
||
sheet.getRange(`F2:F${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||
sheet.getRange(`E2:E${rows.length + 1}`).conditionalFormats.add("containsText", {
|
||
text: "未映射",
|
||
format: { fill: "#FEE2E2", font: { color: "#991B1B", bold: true } },
|
||
});
|
||
sheet.freezePanes.freezeRows(1);
|
||
[16, 30, 22, 18, 14, 18, 14, 14].forEach((width, index) => {
|
||
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||
});
|
||
}
|
||
|
||
async function buildBatchSheet(sheet) {
|
||
sheet.showGridLines = false;
|
||
const headers = [
|
||
"类型",
|
||
"月份",
|
||
"开始日期",
|
||
"结束日期",
|
||
"来源文件",
|
||
"车辆数",
|
||
"日期列数",
|
||
"正里程单元格",
|
||
"0 km 单元格",
|
||
"合计里程(km)",
|
||
];
|
||
sheet.getRange("A1:J1").values = [headers];
|
||
sheet.getRange("A1:J1").format = headerFormat();
|
||
const rows = [
|
||
...dailyBatchManifest.map((batch) => [
|
||
batch.type,
|
||
batch.month,
|
||
new Date(`${batch.date_from}T00:00:00+08:00`),
|
||
new Date(`${batch.date_to}T00:00:00+08:00`),
|
||
batch.file,
|
||
batch.vehicle_rows,
|
||
batch.date_columns,
|
||
batch.positive_cells,
|
||
batch.zero_cells,
|
||
batch.total_km,
|
||
]),
|
||
...statisticBatchManifest.map((batch) => [
|
||
batch.type,
|
||
batch.month,
|
||
new Date(`${batch.date_from}T00:00:00+08:00`),
|
||
new Date(`${batch.date_to}T00:00:00+08:00`),
|
||
batch.file,
|
||
batch.vehicle_rows,
|
||
"",
|
||
"",
|
||
"",
|
||
batch.total_km,
|
||
]),
|
||
];
|
||
sheet.getRangeByIndexes(1, 0, rows.length, headers.length).values = rows;
|
||
sheet.getRange(`C2:D${rows.length + 1}`).format.numberFormat = "yyyy-mm-dd";
|
||
sheet.getRange(`F2:J${rows.length + 1}`).format.numberFormat = "#,##0.00";
|
||
sheet.freezePanes.freezeRows(1);
|
||
[18, 12, 14, 14, 36, 12, 12, 18, 18, 18].forEach((width, index) => {
|
||
sheet.getRangeByIndexes(0, index, rows.length + 1, 1).format.columnWidth = width;
|
||
});
|
||
}
|
||
|
||
async function readFirstSheetValues(file) {
|
||
const input = await FileBlob.load(file);
|
||
const workbook = await SpreadsheetFile.importXlsx(input);
|
||
const sheet = workbook.worksheets.getItemAt(0);
|
||
const usedRange = sheet.getUsedRange(true);
|
||
return usedRange?.values ?? [];
|
||
}
|
||
|
||
async function sortedXlsxFiles(directory) {
|
||
const names = (await fs.readdir(directory))
|
||
.filter((name) => name.endsWith(".xlsx"))
|
||
.sort((a, b) => extractTaskId(a) - extractTaskId(b));
|
||
return names.map((name) => path.join(directory, name));
|
||
}
|
||
|
||
function extractTaskId(file) {
|
||
const match = path.basename(file).match(/-(\d+)\.xlsx$/);
|
||
assert(match, `无法从文件名解析任务号:${file}`);
|
||
return Number(match[1]);
|
||
}
|
||
|
||
function buildMonths() {
|
||
const result = [];
|
||
for (let year = 2022, month = 1; year < 2026 || (year === 2026 && month <= 7); ) {
|
||
const naturalLastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||
const lastDay = year === 2026 && month === 7 ? 20 : naturalLastDay;
|
||
result.push({
|
||
key: `${year}-${pad(month)}`,
|
||
year,
|
||
month,
|
||
dayCount: lastDay,
|
||
dateFrom: `${year}-${pad(month)}-01`,
|
||
dateTo: `${year}-${pad(month)}-${pad(lastDay)}`,
|
||
});
|
||
month += 1;
|
||
if (month === 13) {
|
||
year += 1;
|
||
month = 1;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function readPriorMapping(csvPath) {
|
||
const text = await fs.readFile(csvPath, "utf8");
|
||
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
||
const result = new Map();
|
||
for (const line of lines.slice(1)) {
|
||
if (!line) continue;
|
||
const columns = parseCsvLine(line);
|
||
const [vin, plate, phone] = columns;
|
||
if (!vin || !plate) continue;
|
||
const current = result.get(plate);
|
||
if (current && current.vin !== vin) {
|
||
result.delete(plate);
|
||
continue;
|
||
}
|
||
result.set(plate, { vin, phone: phone ?? "" });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function writeImportCsv(file, vehicleRows, dates) {
|
||
const stream = fsSync.createWriteStream(file, { encoding: "utf8" });
|
||
stream.write("\uFEFFplate,date,daily_mileage_km\n");
|
||
for (const vehicle of vehicleRows) {
|
||
for (const date of dates) {
|
||
const value = vehicle.values.get(date) ?? 0;
|
||
if (!stream.write(`${csvCell(vehicle.plate)},${date},${numberText(value)}\n`)) {
|
||
await once(stream, "drain");
|
||
}
|
||
}
|
||
}
|
||
stream.end();
|
||
await once(stream, "finish");
|
||
}
|
||
|
||
function headerFormat() {
|
||
return {
|
||
fill: "#0F766E",
|
||
font: { bold: true, color: "#FFFFFF" },
|
||
horizontalAlignment: "center",
|
||
verticalAlignment: "center",
|
||
wrapText: true,
|
||
borders: { preset: "outside", style: "thin", color: "#115E59" },
|
||
};
|
||
}
|
||
|
||
function findHeader(headers, names) {
|
||
return headers.findIndex((header) => names.includes(header));
|
||
}
|
||
|
||
function parseCsvLine(line) {
|
||
const result = [];
|
||
let value = "";
|
||
let quoted = false;
|
||
for (let index = 0; index < line.length; index += 1) {
|
||
const char = line[index];
|
||
if (quoted) {
|
||
if (char === '"' && line[index + 1] === '"') {
|
||
value += '"';
|
||
index += 1;
|
||
} else if (char === '"') {
|
||
quoted = false;
|
||
} else {
|
||
value += char;
|
||
}
|
||
} else if (char === '"') {
|
||
quoted = true;
|
||
} else if (char === ",") {
|
||
result.push(value);
|
||
value = "";
|
||
} else {
|
||
value += char;
|
||
}
|
||
}
|
||
result.push(value);
|
||
return result;
|
||
}
|
||
|
||
function csvCell(value) {
|
||
const text = cleanText(value);
|
||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||
}
|
||
|
||
function cleanText(value) {
|
||
return value === null || value === undefined ? "" : String(value).trim();
|
||
}
|
||
|
||
function numberValue(value) {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number : 0;
|
||
}
|
||
|
||
function numberText(value) {
|
||
return String(round(value));
|
||
}
|
||
|
||
function round(value) {
|
||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||
}
|
||
|
||
function pad(value) {
|
||
return String(value).padStart(2, "0");
|
||
}
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error(message);
|
||
}
|