chore: snapshot production code before Apple Design UI refinement

This commit is contained in:
lingniu
2026-09-17 18:05:57 +08:00
parent 9f0a87f49e
commit f7e7176f88
90 changed files with 8990 additions and 328 deletions
@@ -0,0 +1,115 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath =
"/Users/lingniu/Downloads/智能管车_车辆里程日报-105652096.xlsx";
const input = await FileBlob.load(inputPath);
const workbook = await SpreadsheetFile.importXlsx(input);
const summary = await workbook.inspect({
kind: "workbook,sheet,table",
maxChars: 12000,
tableMaxRows: 8,
tableMaxCols: 40,
tableMaxCellChars: 120,
});
const sheet = workbook.worksheets.getItemAt(0);
const usedRange = sheet.getUsedRange(true);
const rowCount = usedRange?.rowCount ?? 0;
const columnCount = usedRange?.columnCount ?? 0;
const values = usedRange?.values ?? [];
const headers = values[0] ?? [];
const dateStartIndex = 3;
const dateEndIndex = Math.max(dateStartIndex, headers.indexOf("运行时长"));
let positiveDailyCellCount = 0;
let nonZeroVehicleCount = 0;
let noteVehicleCount = 0;
let mismatchVehicleCount = 0;
let totalDailyKm = 0;
let totalColumnKm = 0;
const nonZeroSamples = [];
const noteSamples = [];
for (const row of values.slice(1)) {
const dailyValues = row
.slice(dateStartIndex, dateEndIndex)
.map(numberValue);
const positiveValues = dailyValues.filter((value) => value > 0);
const rowDailyKm = dailyValues.reduce((sum, value) => sum + value, 0);
const rowTotalKm = numberValue(row[2]);
const note = row[dateEndIndex + 1];
positiveDailyCellCount += positiveValues.length;
totalDailyKm += rowDailyKm;
totalColumnKm += rowTotalKm;
if (positiveValues.length > 0) {
nonZeroVehicleCount += 1;
if (nonZeroSamples.length < 20) {
nonZeroSamples.push({
plate: row[0],
organization: row[1],
totalKm: rowTotalKm,
dailyKm: rowDailyKm,
positiveDayCount: positiveValues.length,
});
}
}
if (note !== null && note !== undefined && String(note).trim() !== "") {
noteVehicleCount += 1;
if (noteSamples.length < 20) {
noteSamples.push({ plate: row[0], note });
}
}
if (Math.abs(rowTotalKm - rowDailyKm) > 0.011) {
mismatchVehicleCount += 1;
}
}
const inspectRange = rowCount > 0 && columnCount > 0
? `A1:${columnName(Math.min(columnCount, 40))}${Math.min(rowCount, 15)}`
: "A1:A1";
const preview = await workbook.inspect({
kind: "table",
sheetId: sheet.name,
range: inspectRange,
include: "values,formulas",
maxChars: 25000,
tableMaxRows: 15,
tableMaxCols: 40,
tableMaxCellChars: 120,
});
console.log(JSON.stringify({
inputPath,
sheetName: sheet.name,
rowCount,
columnCount,
dateColumnCount: Math.max(0, dateEndIndex - dateStartIndex),
positiveDailyCellCount,
nonZeroVehicleCount,
noteVehicleCount,
mismatchVehicleCount,
totalDailyKm: round(totalDailyKm),
totalColumnKm: round(totalColumnKm),
nonZeroSamples,
noteSamples,
summary: summary.ndjson,
preview: preview.ndjson,
}, null, 2));
function columnName(columnCount) {
let value = columnCount;
let name = "";
while (value > 0) {
value -= 1;
name = String.fromCharCode(65 + (value % 26)) + name;
value = Math.floor(value / 26);
}
return name;
}
function numberValue(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function round(value) {
return Math.round(value * 1000) / 1000;
}
@@ -0,0 +1,741 @@
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 的逐行明细保存在同目录 CSVG7车辆每日里程_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);
}
+787
View File
@@ -0,0 +1,787 @@
package main
import (
"compress/gzip"
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
)
const (
g7Protocol = "JT808"
g7SourceID = int64(661200)
g7SourceIP = "manual-g7s-excel"
g7SourceCode = "g7s_excel"
g7PlatformName = "G7s Excel Override"
g7QualityReason = "manual_g7s_gps_quarterly_import_20260101_20260630_20260728"
dateFrom = "2026-01-01"
dateTo = "2026-06-30"
expectedDays = 181
)
type mapping struct {
VIN string
Ambiguous bool
}
type sourceStats struct {
Rows int64 `json:"rows"`
Vehicles int `json:"vehicles"`
Dates int `json:"dates"`
Positive int64 `json:"positive_rows"`
Zero int64 `json:"zero_rows"`
TotalKM float64 `json:"total_km"`
MinDate string `json:"min_date"`
MaxDate string `json:"max_date"`
PlateCount int `json:"plate_count"`
}
type stageStats struct {
Rows int64 `json:"rows"`
Vehicles int `json:"vehicles"`
Dates int `json:"dates"`
Positive int64 `json:"positive_rows"`
Zero int64 `json:"zero_rows"`
TotalKM float64 `json:"total_km"`
MappedPlates int `json:"mapped_plates"`
UnmappedPlates int `json:"unmapped_plates"`
AmbiguousPlates int `json:"ambiguous_plates"`
VINCollisionPlates int `json:"vin_collision_plates"`
}
type existingStats struct {
ExistingFinalRows int64 `json:"existing_final_rows"`
NewFinalRows int64 `json:"new_final_rows"`
ChangedExistingFinalRows int64 `json:"changed_existing_final_rows"`
UnchangedExistingFinalRows int64 `json:"unchanged_existing_final_rows"`
OtherSourceFinalRows int64 `json:"other_source_final_rows"`
OtherSelectedCandidateRows int64 `json:"other_selected_candidate_rows"`
ExistingFinalTotalKM float64 `json:"existing_final_total_km"`
}
type result struct {
Mode string `json:"mode"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at"`
Source sourceStats `json:"source"`
Stage stageStats `json:"stage"`
Existing existingStats `json:"existing"`
UnmappedPlates []string `json:"unmapped_plates"`
AmbiguousPlates []string `json:"ambiguous_plates"`
CollisionPlates []string `json:"vin_collision_plates"`
BackupFinal string `json:"backup_final,omitempty"`
BackupCandidates string `json:"backup_candidates,omitempty"`
FinalMatchedRows int64 `json:"final_matched_rows"`
FinalTotalKM float64 `json:"final_total_km"`
CandidateMatchedRows int64 `json:"candidate_matched_rows"`
OtherSelectedRows int64 `json:"other_selected_rows"`
SourceID int64 `json:"source_id"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var inputPath, mappingPath, dsn, backupDir string
var apply bool
var timeout time.Duration
flag.StringVar(&inputPath, "input", "", "normalized CSV or CSV.gz")
flag.StringVar(&mappingPath, "mapping-csv", "", "optional existing vin,plate mapping CSV")
flag.StringVar(&dsn, "mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "MySQL DSN")
flag.StringVar(&backupDir, "backup-dir", "/tmp/g7-gps-quarterly-backup-20260728", "backup directory for apply")
flag.BoolVar(&apply, "apply", false, "apply changes; default is dry-run")
flag.DurationVar(&timeout, "timeout", 30*time.Minute, "overall timeout")
flag.Parse()
if strings.TrimSpace(inputPath) == "" {
return errors.New("-input is required")
}
if strings.TrimSpace(dsn) == "" {
return errors.New("MYSQL_DSN or -mysql-dsn is required")
}
normalized, err := normalizedDSN(dsn)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := sql.Open("mysql", normalized)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(2)
db.SetMaxIdleConns(2)
if err := db.PingContext(ctx); err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
started := time.Now()
source, inputPlates, err := scanInput(inputPath)
if err != nil {
return err
}
if source.MinDate != dateFrom || source.MaxDate != dateTo || source.Dates != expectedDays {
return fmt.Errorf("unexpected input range: %s..%s (%d dates)", source.MinDate, source.MaxDate, source.Dates)
}
mappings, err := loadMappings(ctx, conn)
if err != nil {
return err
}
if mappingPath != "" {
if err := mergeMappingCSV(mappingPath, mappings); err != nil {
return err
}
}
resolution := resolveInputPlates(inputPlates, mappings)
if err := createStage(ctx, conn); err != nil {
return err
}
stage, err := loadStage(ctx, conn, inputPath, resolution)
if err != nil {
return err
}
stage.MappedPlates = len(resolution.Mapped)
stage.UnmappedPlates = len(resolution.Unmapped)
stage.AmbiguousPlates = len(resolution.Ambiguous)
stage.VINCollisionPlates = len(resolution.Collisions)
if stage.Rows != int64(stage.MappedPlates*expectedDays) {
return fmt.Errorf("stage row count mismatch: got %d, expected %d", stage.Rows, stage.MappedPlates*expectedDays)
}
existing, err := inspectExisting(ctx, conn)
if err != nil {
return err
}
out := result{
Mode: "dry_run",
StartedAt: started.Format(time.RFC3339),
Source: source,
Stage: stage,
Existing: existing,
UnmappedPlates: resolution.Unmapped,
AmbiguousPlates: resolution.Ambiguous,
CollisionPlates: resolution.Collisions,
SourceID: g7SourceID,
}
if apply {
out.Mode = "apply"
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return err
}
out.BackupFinal = filepath.Join(backupDir, "vehicle_daily_mileage_before.csv.gz")
out.BackupCandidates = filepath.Join(backupDir, "vehicle_daily_mileage_source_before.csv.gz")
if err := backupQuery(ctx, conn, out.BackupFinal, `
SELECT m.*
FROM vehicle_daily_mileage m
JOIN tmp_g7_mileage_import s
ON s.vin = m.vin AND s.stat_date = m.stat_date
WHERE m.protocol = 'JT808'
ORDER BY m.vin, m.stat_date`); err != nil {
return fmt.Errorf("backup final rows: %w", err)
}
if err := backupQuery(ctx, conn, out.BackupCandidates, `
SELECT c.*
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
ORDER BY c.vin, c.stat_date, c.source_key`); err != nil {
return fmt.Errorf("backup candidate rows: %w", err)
}
finalRows, finalTotal, candidateRows, otherSelected, err := applyImport(ctx, conn)
if err != nil {
return err
}
out.FinalMatchedRows = finalRows
out.FinalTotalKM = finalTotal
out.CandidateMatchedRows = candidateRows
out.OtherSelectedRows = otherSelected
}
out.CompletedAt = time.Now().Format(time.RFC3339)
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(out)
}
type plateResolution struct {
Mapped map[string]string
Unmapped []string
Ambiguous []string
Collisions []string
}
func resolveInputPlates(plates map[string]struct{}, mappings map[string]mapping) plateResolution {
result := plateResolution{Mapped: make(map[string]string)}
vinToPlates := make(map[string][]string)
for plate := range plates {
value, ok := mappings[plate]
switch {
case !ok:
result.Unmapped = append(result.Unmapped, plate)
case value.Ambiguous:
result.Ambiguous = append(result.Ambiguous, plate)
default:
result.Mapped[plate] = value.VIN
vinToPlates[value.VIN] = append(vinToPlates[value.VIN], plate)
}
}
for _, presentPlates := range vinToPlates {
if len(presentPlates) < 2 {
continue
}
for _, plate := range presentPlates {
delete(result.Mapped, plate)
result.Collisions = append(result.Collisions, plate)
}
}
sort.Strings(result.Unmapped)
sort.Strings(result.Ambiguous)
sort.Strings(result.Collisions)
return result
}
func scanInput(inputPath string) (sourceStats, map[string]struct{}, error) {
reader, closeReader, err := openCSV(inputPath)
if err != nil {
return sourceStats{}, nil, err
}
defer closeReader()
csvReader := csv.NewReader(reader)
header, err := csvReader.Read()
if err != nil {
return sourceStats{}, nil, err
}
if len(header) < 3 {
return sourceStats{}, nil, errors.New("input CSV needs plate,date,daily_mileage_km")
}
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
if header[0] != "plate" || header[1] != "date" || header[2] != "daily_mileage_km" {
return sourceStats{}, nil, fmt.Errorf("unexpected CSV header: %v", header)
}
plates := make(map[string]struct{})
dates := make(map[string]struct{})
stats := sourceStats{}
for {
record, readErr := csvReader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return sourceStats{}, nil, readErr
}
if len(record) < 3 {
return sourceStats{}, nil, fmt.Errorf("short CSV row at %d", stats.Rows+2)
}
plate := strings.TrimSpace(record[0])
date := strings.TrimSpace(record[1])
mileage, parseErr := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
if parseErr != nil || mileage < 0 || mileage > 2500 {
return sourceStats{}, nil, fmt.Errorf("invalid mileage at row %d: %q", stats.Rows+2, record[2])
}
if _, parseErr = time.Parse("2006-01-02", date); parseErr != nil {
return sourceStats{}, nil, fmt.Errorf("invalid date at row %d: %q", stats.Rows+2, date)
}
if plate == "" {
return sourceStats{}, nil, fmt.Errorf("empty plate at row %d", stats.Rows+2)
}
plates[plate] = struct{}{}
dates[date] = struct{}{}
stats.Rows++
stats.TotalKM += mileage
if mileage > 0 {
stats.Positive++
} else {
stats.Zero++
}
if stats.MinDate == "" || date < stats.MinDate {
stats.MinDate = date
}
if date > stats.MaxDate {
stats.MaxDate = date
}
}
stats.PlateCount = len(plates)
stats.Vehicles = len(plates)
stats.Dates = len(dates)
stats.TotalKM = round(stats.TotalKM)
return stats, plates, nil
}
func loadMappings(ctx context.Context, conn *sql.Conn) (map[string]mapping, error) {
rows, err := conn.QueryContext(ctx, `
SELECT plate, vin
FROM (
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
FROM vehicle
WHERE plate IS NOT NULL AND TRIM(plate) <> ''
AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT TRIM(plate) AS plate, TRIM(vin) AS vin
FROM vehicle_identifier
WHERE enabled = 1 AND plate IS NOT NULL AND TRIM(plate) <> ''
AND vin IS NOT NULL AND TRIM(vin) <> ''
) mappings`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]mapping)
for rows.Next() {
var plate, vin string
if err := rows.Scan(&plate, &vin); err != nil {
return nil, err
}
current, ok := result[plate]
if !ok {
result[plate] = mapping{VIN: vin}
} else if current.VIN != vin {
current.Ambiguous = true
result[plate] = current
}
}
return result, rows.Err()
}
func mergeMappingCSV(inputPath string, mappings map[string]mapping) error {
file, err := os.Open(inputPath)
if err != nil {
return err
}
defer file.Close()
reader := csv.NewReader(file)
header, err := reader.Read()
if err != nil {
return err
}
if len(header) < 2 {
return errors.New("mapping CSV must contain vin,plate")
}
header[0] = strings.TrimPrefix(header[0], "\uFEFF")
if header[0] != "vin" || header[1] != "plate" {
return fmt.Errorf("unexpected mapping CSV header: %v", header[:2])
}
for {
record, readErr := reader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return readErr
}
if len(record) < 2 {
continue
}
vin := strings.TrimSpace(record[0])
plate := strings.TrimSpace(record[1])
if vin == "" || plate == "" {
continue
}
current, ok := mappings[plate]
if !ok {
mappings[plate] = mapping{VIN: vin}
} else if current.VIN != vin {
current.Ambiguous = true
mappings[plate] = current
}
}
return nil
}
func createStage(ctx context.Context, conn *sql.Conn) error {
_, err := conn.ExecContext(ctx, `
DROP TEMPORARY TABLE IF EXISTS tmp_g7_mileage_import`)
if err != nil {
return err
}
_, err = conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_g7_mileage_import (
vin varchar(32) NOT NULL,
plate varchar(32) NOT NULL,
stat_date date NOT NULL,
daily_mileage_km decimal(18,3) NOT NULL,
PRIMARY KEY (vin, stat_date),
KEY idx_tmp_plate (plate),
KEY idx_tmp_date (stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`)
return err
}
func loadStage(ctx context.Context, conn *sql.Conn, inputPath string, resolution plateResolution) (stageStats, error) {
reader, closeReader, err := openCSV(inputPath)
if err != nil {
return stageStats{}, err
}
defer closeReader()
csvReader := csv.NewReader(reader)
if _, err := csvReader.Read(); err != nil {
return stageStats{}, err
}
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return stageStats{}, err
}
defer tx.Rollback()
type stageRow struct {
VIN, Plate, Date string
Mileage float64
}
batch := make([]stageRow, 0, 1000)
flush := func() error {
if len(batch) == 0 {
return nil
}
var query strings.Builder
query.WriteString("INSERT INTO tmp_g7_mileage_import (vin, plate, stat_date, daily_mileage_km) VALUES ")
args := make([]any, 0, len(batch)*4)
for index, row := range batch {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?)")
args = append(args, row.VIN, row.Plate, row.Date, row.Mileage)
}
if _, execErr := tx.ExecContext(ctx, query.String(), args...); execErr != nil {
return execErr
}
batch = batch[:0]
return nil
}
for {
record, readErr := csvReader.Read()
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
return stageStats{}, readErr
}
plate := strings.TrimSpace(record[0])
vin, ok := resolution.Mapped[plate]
if !ok {
continue
}
mileage, _ := strconv.ParseFloat(strings.TrimSpace(record[2]), 64)
batch = append(batch, stageRow{
VIN: vin, Plate: plate, Date: strings.TrimSpace(record[1]), Mileage: mileage,
})
if len(batch) == cap(batch) {
if err := flush(); err != nil {
return stageStats{}, err
}
}
}
if err := flush(); err != nil {
return stageStats{}, err
}
if err := tx.Commit(); err != nil {
return stageStats{}, err
}
var stats stageStats
var nullableSum sql.NullFloat64
err = conn.QueryRowContext(ctx, `
SELECT COUNT(*), COUNT(DISTINCT vin), COUNT(DISTINCT stat_date),
SUM(daily_mileage_km > 0), SUM(daily_mileage_km = 0),
SUM(daily_mileage_km)
FROM tmp_g7_mileage_import`).Scan(
&stats.Rows, &stats.Vehicles, &stats.Dates, &stats.Positive, &stats.Zero, &nullableSum,
)
if nullableSum.Valid {
stats.TotalKM = round(nullableSum.Float64)
}
return stats, err
}
func inspectExisting(ctx context.Context, conn *sql.Conn) (existingStats, error) {
var stats existingStats
var nullableSum sql.NullFloat64
err := conn.QueryRowContext(ctx, `
SELECT
SUM(m.vin IS NOT NULL),
SUM(m.vin IS NULL),
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) > 0.0005),
SUM(m.vin IS NOT NULL AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005),
SUM(m.vin IS NOT NULL AND COALESCE(m.source_id, 0) <> ?),
SUM(CASE WHEN m.vin IS NOT NULL THEN m.daily_mileage_km ELSE 0 END)
FROM tmp_g7_mileage_import s
LEFT JOIN vehicle_daily_mileage m
ON m.vin = s.vin AND m.stat_date = s.stat_date AND m.protocol = 'JT808'`, g7SourceID).Scan(
&stats.ExistingFinalRows,
&stats.NewFinalRows,
&stats.ChangedExistingFinalRows,
&stats.UnchangedExistingFinalRows,
&stats.OtherSourceFinalRows,
&nullableSum,
)
if err != nil {
return stats, err
}
if nullableSum.Valid {
stats.ExistingFinalTotalKM = round(nullableSum.Float64)
}
err = conn.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808' AND c.is_selected = 1
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')`).Scan(
&stats.OtherSelectedCandidateRows,
)
return stats, err
}
func applyImport(ctx context.Context, conn *sql.Conn) (int64, float64, int64, int64, error) {
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return 0, 0, 0, 0, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
SET c.is_selected = 0
WHERE c.protocol = 'JT808'
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected <> 0`); err != nil {
return 0, 0, 0, 0, fmt.Errorf("clear other selected candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage_source (
vin, stat_date, protocol, source_key, source_ip, source_endpoint,
platform_name, first_total_mileage_km, latest_total_mileage_km,
daily_mileage_km, sample_count, first_event_time, latest_event_time,
quality_status, quality_reason, is_selected
)
SELECT
s.vin, s.stat_date, 'JT808',
CONCAT('JT808:', s.vin, '@PLATFORM:g7s_excel'),
'manual-g7s-excel', 'manual-g7s-excel',
'G7s Excel Override', NULL, NULL,
s.daily_mileage_km, 1,
TIMESTAMP(s.stat_date, '00:00:00'),
TIMESTAMP(s.stat_date, '23:59:59'),
'OK', ?, 1
FROM tmp_g7_mileage_import s
ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
platform_name = VALUES(platform_name),
first_total_mileage_km = NULL,
latest_total_mileage_km = NULL,
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
is_selected = 1`, g7QualityReason); err != nil {
return 0, 0, 0, 0, fmt.Errorf("upsert G7 candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage (
vin, stat_date, protocol, source_id, daily_mileage_km,
latest_total_mileage_km, updated_at
)
SELECT vin, stat_date, 'JT808', ?, daily_mileage_km, NULL, NOW()
FROM tmp_g7_mileage_import
ON DUPLICATE KEY UPDATE
source_id = VALUES(source_id),
daily_mileage_km = VALUES(daily_mileage_km),
latest_total_mileage_km = NULL,
updated_at = NOW()`, g7SourceID); err != nil {
return 0, 0, 0, 0, fmt.Errorf("upsert final mileage: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_data_source
SET latest_seen_at = GREATEST(COALESCE(latest_seen_at, '2026-06-30 23:59:59'), '2026-06-30 23:59:59'),
first_seen_at = LEAST(COALESCE(first_seen_at, '2026-01-01 00:00:00'), '2026-01-01 00:00:00'),
latest_source_endpoint = 'manual-g7s-excel',
platform_name = 'G7s Excel Override',
source_code = 'g7s_excel',
source_kind = 'PLATFORM',
trust_priority = 0,
enabled = 1,
remark = 'Manual GPS override imported from G7s quarterly daily mileage reports for 2026-01-01 through 2026-06-30 on 2026-07-28'
WHERE id = ? AND protocol = 'JT808' AND source_ip = 'manual-g7s-excel'`, g7SourceID); err != nil {
return 0, 0, 0, 0, fmt.Errorf("update G7 source metadata: %w", err)
}
var finalRows, candidateRows, otherSelected int64
var finalSum sql.NullFloat64
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*), SUM(m.daily_mileage_km)
FROM vehicle_daily_mileage m
JOIN tmp_g7_mileage_import s
ON s.vin = m.vin AND s.stat_date = m.stat_date
WHERE m.protocol = 'JT808' AND m.source_id = ?
AND ABS(m.daily_mileage_km - s.daily_mileage_km) <= 0.0005`, g7SourceID).Scan(
&finalRows, &finalSum,
); err != nil {
return 0, 0, 0, 0, err
}
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
AND c.source_key = CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected = 1
AND ABS(c.daily_mileage_km - s.daily_mileage_km) <= 0.0005`).Scan(&candidateRows); err != nil {
return 0, 0, 0, 0, err
}
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source c
JOIN tmp_g7_mileage_import s
ON s.vin = c.vin AND s.stat_date = c.stat_date
WHERE c.protocol = 'JT808'
AND c.source_key <> CONCAT('JT808:', c.vin, '@PLATFORM:g7s_excel')
AND c.is_selected = 1`).Scan(&otherSelected); err != nil {
return 0, 0, 0, 0, err
}
var stageRows int64
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tmp_g7_mileage_import`).Scan(&stageRows); err != nil {
return 0, 0, 0, 0, err
}
if finalRows != stageRows || candidateRows != stageRows || otherSelected != 0 {
return 0, 0, 0, 0, fmt.Errorf(
"verification failed before commit: stage=%d final=%d candidates=%d other_selected=%d",
stageRows, finalRows, candidateRows, otherSelected,
)
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, 0, err
}
return finalRows, round(finalSum.Float64), candidateRows, otherSelected, nil
}
func backupQuery(ctx context.Context, conn *sql.Conn, outputPath, query string) error {
rows, err := conn.QueryContext(ctx, query)
if err != nil {
return err
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return err
}
file, err := os.OpenFile(outputPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
csvWriter := csv.NewWriter(gzipWriter)
defer csvWriter.Flush()
if err := csvWriter.Write(columns); err != nil {
return err
}
raw := make([]sql.RawBytes, len(columns))
pointers := make([]any, len(columns))
for index := range raw {
pointers[index] = &raw[index]
}
for rows.Next() {
if err := rows.Scan(pointers...); err != nil {
return err
}
record := make([]string, len(columns))
for index, value := range raw {
if value == nil {
record[index] = "\\N"
} else {
record[index] = string(value)
}
}
if err := csvWriter.Write(record); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return err
}
if err := gzipWriter.Close(); err != nil {
return err
}
return file.Sync()
}
func openCSV(inputPath string) (io.Reader, func(), error) {
file, err := os.Open(inputPath)
if err != nil {
return nil, nil, err
}
closeReader := func() { _ = file.Close() }
if !strings.HasSuffix(strings.ToLower(inputPath), ".gz") {
return file, closeReader, nil
}
gzipReader, err := gzip.NewReader(file)
if err != nil {
file.Close()
return nil, nil, err
}
return gzipReader, func() {
_ = gzipReader.Close()
_ = file.Close()
}, nil
}
func normalizedDSN(raw string) (string, error) {
config, err := mysql.ParseDSN(raw)
if err != nil {
return "", err
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return "", err
}
config.ParseTime = true
config.Loc = location
config.Timeout = 10 * time.Second
config.ReadTimeout = 30 * time.Minute
config.WriteTimeout = 30 * time.Minute
config.Params = cloneParams(config.Params)
config.Params["charset"] = "utf8mb4"
return config.FormatDSN(), nil
}
func cloneParams(input map[string]string) map[string]string {
output := make(map[string]string, len(input)+1)
for key, value := range input {
output[key] = value
}
return output
}
func round(value float64) float64 {
return float64(int64(value*100+0.5)) / 100
}
@@ -0,0 +1,66 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
panic("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
panic(err)
}
defer db.Close()
for _, table := range []string{
"vehicle_data_source",
"vehicle_daily_mileage_source",
"vehicle_daily_mileage",
"vehicle",
"vehicle_identifier",
} {
var name, ddl string
if err := db.QueryRow("SHOW CREATE TABLE "+table).Scan(&name, &ddl); err != nil {
panic(err)
}
fmt.Printf("=== %s ===\n%s\n", name, ddl)
}
rows, err := db.Query("SELECT * FROM vehicle_data_source WHERE id = 661200")
if err != nil {
panic(err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
panic(err)
}
if rows.Next() {
values := make([]any, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
if err := rows.Scan(pointers...); err != nil {
panic(err)
}
record := make(map[string]any, len(columns))
for i, column := range columns {
switch value := values[i].(type) {
case []byte:
record[column] = string(value)
default:
record[column] = value
}
}
data, _ := json.Marshal(record)
fmt.Printf("=== source_661200 ===\n%s\n", data)
}
}
@@ -0,0 +1,41 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程及里程统计_20220101-20260720.xlsx";
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
const sheets = await workbook.inspect({
kind: "sheet",
include: "id,name",
maxChars: 4000,
});
const summary = await workbook.inspect({
kind: "table",
sheetId: "汇总",
range: "A1:H25",
include: "values,formulas",
maxChars: 12000,
tableMaxRows: 25,
tableMaxCols: 8,
});
const daily = await workbook.inspect({
kind: "table",
sheetId: "每日里程非零",
range: "A1:D12",
include: "values,formulas",
maxChars: 6000,
tableMaxRows: 12,
tableMaxCols: 4,
});
const errors = await workbook.inspect({
kind: "match",
searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",
options: { useRegex: true, maxResults: 300 },
summary: "post-export formula error scan",
});
console.log(JSON.stringify({
inputPath,
sheets: sheets.ndjson,
summary: summary.ndjson,
daily: daily.ndjson,
errors: errors.ndjson,
}, null, 2));
@@ -0,0 +1,364 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const taskDir = process.env.GB32960_TASK_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gb32960-export-20260817";
const outputDir = process.env.GB32960_OUTPUT_DIR || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377";
const data = JSON.parse(await fs.readFile(path.join(taskDir, "raw-frames.json"), "utf8"));
const exportDate = data.query.dateFrom.slice(0, 10);
const outputPath = path.join(outputDir, `${data.query.plate}_${exportDate}_GB32960原始数据_中文字段.xlsx`);
const categoryNames = {
alarm: "报警数据",
drive_motor: "驱动电机数据",
engine: "发动机数据",
extreme: "极值数据",
fuel_cell: "燃料电池数据",
gd_fc_air_conditioner: "广东燃料电池空调数据",
gd_fc_auxiliary: "广东燃料电池辅助系统数据",
gd_fc_dcdc: "广东燃料电池 DC/DC 数据",
gd_fc_demo_extension: "广东燃料电池演示扩展数据",
gd_fc_stack: "广东燃料电池电堆数据",
gd_fc_vehicle_info: "广东燃料电池车辆信息数据",
gd_fc_vendor_tlv: "广东燃料电池厂家 TLV 扩展",
header: "报文头",
platform: "平台信息",
position: "车辆位置数据",
temperature: "可充电储能装置温度数据",
vehicle: "整车数据",
voltage: "可充电储能装置电压数据",
};
const fieldNames = {
battery_faults: "可充电储能装置故障码",
engine_faults: "发动机故障码",
general_alarm_flag: "通用报警标志",
max_alarm_level: "最高报警等级",
motor_faults: "驱动电机故障码",
other_faults: "其他故障码",
type: "数据单元类型",
count: "数量",
controller_current_a: "控制器直流母线电流",
controller_temperature_c: "控制器温度",
controller_voltage_v: "控制器输入电压",
motor_temperature_c: "电机温度",
serial_no: "驱动电机序号",
speed_rpm: "转速",
state: "状态",
torque_nm: "转矩",
engine_status: "发动机状态",
fuel_rate: "燃料消耗率",
max_temp_c: "最高温度",
max_temp_probe_no: "最高温度探针序号",
max_temp_subsystem_no: "最高温度子系统号",
max_voltage_cell_no: "最高电压单体序号",
max_voltage_subsystem_no: "最高电压子系统号",
max_voltage_v: "最高电压",
min_temp_c: "最低温度",
min_temp_probe_no: "最低温度探针序号",
min_temp_subsystem_no: "最低温度子系统号",
min_voltage_cell_no: "最低电压单体序号",
min_voltage_subsystem_no: "最低电压子系统号",
min_voltage_v: "最低电压",
dc_dc_status: "高压 DC/DC 状态",
fuel_cell_current_a: "燃料电池电流",
fuel_cell_voltage_v: "燃料电池电压",
hydrogen_consumption_kg_per_100km: "氢耗",
max_hydrogen_concentration_fraction: "最高氢浓度比例",
max_hydrogen_concentration_percent: "最高氢浓度",
max_hydrogen_concentration_ppm: "最高氢浓度原始值",
max_hydrogen_concentration_probe_id: "最高氢浓度探针编号",
max_hydrogen_pressure_mpa: "最高氢压力",
max_hydrogen_pressure_probe_id: "最高氢压探针编号",
max_hydrogen_temperature_c: "氢系统最高温度",
max_hydrogen_temperature_probe_id: "最高氢温探针编号",
temperature_probe_count: "温度探针数量",
temperature_probe_values_c: "温度探针值列表",
status: "状态",
subsystem_count: "子系统数量",
air_compressor_motor_voltage_v: "空气压缩机电机电压",
air_compressor_power_kw: "空气压缩机功率",
low_voltage_battery_voltage_v: "低压蓄电池电压",
water_pump_voltage_v: "水泵电压",
controller_temp_c: "控制器温度",
input_current_a: "输入电流",
input_voltage_v: "输入电压",
output_current_a: "输出电流",
output_voltage_v: "输出电压",
air_compressor_current_a: "空气压缩机电流",
air_compressor_voltage_v: "空气压缩机电压",
declared_length: "声明长度",
hydrogen_pump_current_a: "氢气循环泵电流",
hydrogen_pump_voltage_v: "氢气循环泵电压",
stack_temp_c: "电堆温度",
air_inlet_pressure_kpa: "空气入口压力",
avg_cell_voltage_v: "单体平均电压",
cell_count: "单体总数",
engine_work_state: "发动机工作状态",
frame_cell_voltages_v: "本帧单体电压列表",
hydrogen_inlet_pressure_kpa: "氢气入口压力",
max_cell_voltage_id: "最高电压单体编号",
max_cell_voltage_v: "单体最高电压",
min_cell_voltage_id: "最低电压单体编号",
min_cell_voltage_v: "单体最低电压",
stack_count: "电堆数量",
stack_water_outlet_temp_c: "电堆出水温度",
collision_alarm: "碰撞报警",
hydrogen_mass_kg: "车载氢量",
payload_hex: "原始载荷(十六进制)",
actual_body_length: "实际报文体长度",
body_length: "报文体长度",
command: "命令标识",
encrypt: "加密方式",
response_flag: "应答标志",
version: "协议版本",
vin: "车辆识别代号",
platform_name: "平台名称",
latitude: "纬度",
longitude: "经度",
position_status: "定位状态标志",
accelerator_pct: "加速踏板行程",
brake_pct: "制动踏板状态",
charge_status: "充电状态",
gear: "挡位原始值",
insulation_kohm: "绝缘电阻",
running_mode: "运行模式",
soc_percent: "SOC",
speed_kmh: "车速",
total_current_a: "总电流",
total_mileage_km: "累计里程",
total_voltage_v: "总电压",
vehicle_status: "车辆状态",
max_cell_v: "单体最高电压",
min_cell_v: "单体最低电压",
};
const unitBySuffix = {
controller_current_a: "A", controller_voltage_v: "V", controller_temperature_c: "℃",
motor_temperature_c: "℃", speed_rpm: "rpm", torque_nm: "N·m", fuel_rate: "L/100km",
max_temp_c: "℃", min_temp_c: "℃", max_voltage_v: "V", min_voltage_v: "V",
fuel_cell_current_a: "A", fuel_cell_voltage_v: "V", hydrogen_consumption_kg_per_100km: "kg/100km",
max_hydrogen_concentration_fraction: "比例", max_hydrogen_concentration_percent: "%", max_hydrogen_concentration_ppm: "ppm",
max_hydrogen_pressure_mpa: "MPa", max_hydrogen_temperature_c: "℃", temperature_probe_values_c: "℃",
air_compressor_motor_voltage_v: "V", air_compressor_power_kw: "kW", low_voltage_battery_voltage_v: "V", water_pump_voltage_v: "V",
controller_temp_c: "℃", input_current_a: "A", input_voltage_v: "V", output_current_a: "A", output_voltage_v: "V",
air_compressor_current_a: "A", air_compressor_voltage_v: "V", hydrogen_pump_current_a: "A", hydrogen_pump_voltage_v: "V", stack_temp_c: "℃",
air_inlet_pressure_kpa: "kPa", avg_cell_voltage_v: "V", frame_cell_voltages_v: "V", hydrogen_inlet_pressure_kpa: "kPa",
max_cell_voltage_v: "V", min_cell_voltage_v: "V", stack_water_outlet_temp_c: "℃", hydrogen_mass_kg: "kg",
latitude: "°", longitude: "°", accelerator_pct: "%", brake_pct: "%", insulation_kohm: "kΩ", soc_percent: "%",
speed_kmh: "km/h", total_current_a: "A", total_mileage_km: "km", total_voltage_v: "V", max_cell_v: "V", min_cell_v: "V",
};
const valueMappings = {
"gb32960.vehicle.vehicle_status": { "1": "启动", "2": "熄火", "3": "其他", "254": "异常", "255": "无效" },
"gb32960.vehicle.charge_status": { "1": "停车充电", "2": "行驶充电", "3": "未充电", "4": "充电完成", "254": "异常", "255": "无效" },
"gb32960.vehicle.running_mode": { "1": "纯电", "2": "混合动力", "3": "燃料电池", "254": "异常", "255": "无效" },
"gb32960.vehicle.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
"gb32960.fuel_cell.dc_dc_status": { "1": "工作", "2": "断开", "254": "异常", "255": "无效" },
"gb32960.drive_motor.motors.motor_1.state": { "1": "耗电", "2": "发电", "3": "关闭", "4": "准备", "254": "异常", "255": "无效" },
"gb32960.alarm.max_alarm_level": { "0": "无故障", "1": "一级故障", "2": "二级故障", "3": "三级故障" },
"gb32960.gd_fc_air_conditioner.status": { "0": "关闭", "1": "启动", "254": "异常", "255": "无效" },
"gb32960.gd_fc_vehicle_info.collision_alarm": { "0": "无碰撞报警", "1": "有碰撞报警", "254": "异常", "255": "无效" },
"gb32960.position.position_status": {
"0": "有效定位 · 北纬 · 东经", "1": "无效定位 · 北纬 · 东经", "2": "有效定位 · 南纬 · 东经", "3": "无效定位 · 南纬 · 东经",
"4": "有效定位 · 北纬 · 西经", "5": "无效定位 · 北纬 · 西经", "6": "有效定位 · 南纬 · 西经", "7": "无效定位 · 南纬 · 西经",
},
};
function fieldParts(key) {
const parts = key.split(".");
const category = parts[1] ?? "";
const suffix = parts.at(-1) ?? key;
const motorMatch = key.match(/\.motors\.motor_(\d+)\./);
const subsystemMatch = key.match(/\.subsystems\.subsystem_(\d+)\./);
const scope = motorMatch ? `电机${motorMatch[1]}-` : subsystemMatch ? `子系统${subsystemMatch[1]}-` : "";
const categoryName = categoryNames[category] ?? "GB/T 32960 扩展数据";
const label = fieldNames[suffix];
if (!label) throw new Error(`missing Chinese field mapping for ${key}`);
const unit = unitBySuffix[suffix] ?? "";
return { category, categoryName, suffix, label: `${categoryName}-${scope}${label}${unit ? `${unit}` : ""}`, unit };
}
function displayValue(key, value) {
if (value == null || value === "") return null;
const raw = String(value);
const mapped = valueMappings[key]?.[raw];
if (mapped) return `${mapped}${raw}`;
if (/^0x[0-9a-f]+$/i.test(raw)) return `十六进制 ${raw}`;
if (/^-?\d+(?:\.\d+)?$/.test(raw) && !key.endsWith(".vin")) return Number(raw);
return raw;
}
function mappingText(key) {
const mapping = valueMappings[key];
return mapping ? Object.entries(mapping).map(([value, label]) => `${value}=${label}`).join("") : "";
}
function columnName(index) {
let value = index + 1;
let out = "";
while (value > 0) {
const remainder = (value - 1) % 26;
out = String.fromCharCode(65 + remainder) + out;
value = Math.floor((value - 1) / 26);
}
return out;
}
const fieldKeys = data.fieldKeys;
const mappedFields = fieldKeys.map((key) => ({ key, ...fieldParts(key) }));
const metadata = [
["序号", (item, index) => index + 1],
["车牌", () => data.query.plate],
["VIN", (item) => item.vin],
["协议", (item) => item.protocol],
["设备时间(北京时间)", (item) => item.event_time],
["接收时间(北京时间)", (item) => item.received_at],
["存储时间(北京时间)", (item) => item.ts],
["消息ID", (item) => item.message_id],
["消息ID(十六进制)", (item) => item.message_id_hex ? `十六进制 ${item.message_id_hex}` : null],
["帧ID", (item) => item.frame_id],
["事件ID", (item) => item.event_id],
["解析状态", (item) => item.parse_status],
["解析错误", (item) => item.parse_error ?? null],
["来源端点", (item) => item.source_endpoint],
["原始报文字节数", (item) => item.raw_size_bytes],
["车辆标识", (item) => item.vehicle_key],
["手机号", (item) => item.phone ?? null],
["设备ID", (item) => item.device_id ?? null],
["原始文本", (item) => item.raw_text ?? null],
["原始报文(十六进制)", (item) => item.raw_hex ?? null],
];
const workbook = Workbook.create();
const summary = workbook.worksheets.add("导出说明");
const detail = workbook.worksheets.add("完整数据");
const mapping = workbook.worksheets.add("字段映射");
summary.showGridLines = false;
summary.getRange("A1:F1").merge();
summary.getRange("A1").values = [[`${data.query.plate} · ${exportDate} · GB/T 32960 原始数据导出`]];
summary.getRange("A1:F1").format = { fill: "#163A5F", font: { bold: true, color: "#FFFFFF", size: 16 }, verticalAlignment: "center" };
summary.getRange("A1:F1").format.rowHeight = 32;
summary.getRange("A3:B15").values = [
["车牌", data.query.plate],
["VIN", data.query.vin],
["协议", data.query.protocol],
["查询日期", `${exportDate}(北京时间)`],
["查询时间窗", `${data.query.dateFrom}${data.query.dateTo}`],
["接口统计总帧数", data.verification.apiTotal],
["实际导出帧数", data.verification.fetchedRows],
["唯一帧ID数", data.verification.uniqueFrameIds],
["唯一事件ID数", data.verification.uniqueEventIds],
["首条存储时间", data.verification.earliestTs],
["末条存储时间", data.verification.latestTs],
["解析字段数", data.verification.parsedFieldCount],
["消息类型统计", Object.entries(data.verification.messageCounts).map(([key, count]) => `${key}: ${count}`).join("")],
];
summary.getRange("A3:A15").format = { fill: "#DCE8F2", font: { bold: true, color: "#163A5F" } };
summary.getRange("A3:B15").format.borders = { preset: "inside", style: "thin", color: "#C8D4E0" };
summary.getRange("A17:F17").merge();
summary.getRange("A17").values = [["口径说明"]];
summary.getRange("A17:F17").format = { fill: "#2B6F91", font: { bold: true, color: "#FFFFFF" } };
summary.getRange("A18:F22").merge(true);
summary.getRange("A18:F22").values = [
[`1. 数据源为生产实时 API 的 GB32960 原始帧历史;导出保留原始报文十六进制、帧/事件标识、解析状态及全部 ${data.verification.parsedFieldCount} 个已出现的解析字段。`],
["2. 完整数据工作表使用中文字段名;字段映射工作表保留原始字段键、单位及状态值映射,便于审计和程序回溯。"],
["3. 状态类字段显示为“中文含义(协议值)”;普通测量值保持数值,列表/位图/十六进制载荷保持原始文本。"],
[`4. 查询边界为北京时间 ${data.query.dateFrom}${data.query.dateTo};该车辆实际存储时间覆盖 ${data.verification.earliestTs}${data.verification.latestTs}`],
["5. 中文术语参考:项目 GB/T 32960 字段展示基准及【字段】32960+广东燃料电池协议.jsonSHA-256: 0f0847b86cc692f2130255babbee37e6cb3cf36d717b18d6698164289f66eb82)。"],
];
summary.getRange("A18:F22").format = { wrapText: true, verticalAlignment: "top", font: { color: "#334155", size: 10 } };
summary.getRange("A18:F22").format.rowHeight = 34;
summary.getRange("A:A").format.columnWidth = 22;
summary.getRange("B:F").format.columnWidth = 18;
summary.getRange("B:B").format.columnWidth = 78;
const detailHeaders = [...metadata.map(([label]) => label), ...mappedFields.map((field) => field.label)];
const detailRows = data.items.map((item, index) => [
...metadata.map(([, getter]) => getter(item, index)),
...mappedFields.map(({ key }) => displayValue(key, item.parsed_fields?.[key])),
]);
const detailMatrix = [detailHeaders, ...detailRows];
const detailEndColumn = columnName(detailHeaders.length - 1);
detail.getRangeByIndexes(0, 0, detailMatrix.length, detailHeaders.length).values = detailMatrix;
detail.showGridLines = false;
detail.freezePanes.freezeRows(1);
detail.freezePanes.freezeColumns(3);
const dataTable = detail.tables.add(`A1:${detailEndColumn}${detailMatrix.length}`, true, "GB32960DataTable");
dataTable.style = "TableStyleMedium2";
dataTable.showFilterButton = true;
dataTable.showBandedColumns = false;
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format = {
fill: "#1F4E78", font: { bold: true, color: "#FFFFFF", size: 9 }, wrapText: true,
horizontalAlignment: "center", verticalAlignment: "center",
};
detail.getRangeByIndexes(0, 0, 1, detailHeaders.length).format.rowHeight = 48;
detail.getRangeByIndexes(1, 0, detailRows.length, detailHeaders.length).format.font = { size: 9, color: "#1F2937" };
detail.getRangeByIndexes(0, 0, detailMatrix.length, 1).format.columnWidth = 8;
detail.getRangeByIndexes(0, 1, detailMatrix.length, 1).format.columnWidth = 13;
detail.getRangeByIndexes(0, 2, detailMatrix.length, 1).format.columnWidth = 22;
for (const col of [3, 7, 8, 11, 14]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 14;
for (const col of [4, 5, 6]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 21;
for (const col of [9, 10]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 36;
for (const col of [12, 13, 15, 16, 17]) detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = 20;
detail.getRangeByIndexes(0, 18, detailMatrix.length, 1).format.columnWidth = 28;
detail.getRangeByIndexes(0, 19, detailMatrix.length, 1).format.columnWidth = 44;
for (let col = metadata.length; col < detailHeaders.length; col += 1) {
const key = mappedFields[col - metadata.length].key;
const wide = key.includes("voltages_v") || key.endsWith("faults") || key.endsWith("payload_hex");
detail.getRangeByIndexes(0, col, detailMatrix.length, 1).format.columnWidth = wide ? 38 : 18;
}
detail.getRangeByIndexes(1, 0, detailRows.length, 1).format.numberFormat = "0";
detail.getRangeByIndexes(1, 7, detailRows.length, 1).format.numberFormat = "0";
detail.getRangeByIndexes(1, 14, detailRows.length, 1).format.numberFormat = "0";
mapping.showGridLines = false;
mapping.freezePanes.freezeRows(1);
const mappingHeaders = ["序号", "中文字段名", "原始字段键", "数据类别", "单位", "状态值映射", "说明"];
const mappingRows = mappedFields.map((field, index) => [
index + 1,
field.label,
field.key,
field.categoryName,
field.unit,
mappingText(field.key),
valueMappings[field.key] ? "完整数据中显示中文含义并保留括号内协议值。" : "值按协议解析结果原样导出;数值字段保持数值类型。",
]);
mapping.getRangeByIndexes(0, 0, mappingRows.length + 1, mappingHeaders.length).values = [mappingHeaders, ...mappingRows];
const mappingTable = mapping.tables.add(`A1:G${mappingRows.length + 1}`, true, "GB32960FieldMappingTable");
mappingTable.style = "TableStyleMedium2";
mapping.getRange("A1:G1").format = { fill: "#1F4E78", font: { bold: true, color: "#FFFFFF" }, wrapText: true, horizontalAlignment: "center" };
mapping.getRange("A1:G1").format.rowHeight = 30;
mapping.getRange("A:A").format.columnWidth = 8;
mapping.getRange("B:B").format.columnWidth = 42;
mapping.getRange("C:C").format.columnWidth = 58;
mapping.getRange("D:D").format.columnWidth = 30;
mapping.getRange("E:E").format.columnWidth = 12;
mapping.getRange("F:F").format.columnWidth = 68;
mapping.getRange("G:G").format.columnWidth = 48;
mapping.getRange(`B2:G${mappingRows.length + 1}`).format.wrapText = true;
await fs.mkdir(outputDir, { recursive: true });
const summaryInspect = await workbook.inspect({ kind: "table", range: "导出说明!A1:F22", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 8 });
const detailInspect = await workbook.inspect({ kind: "table", range: "完整数据!A1:Z8", include: "values,formulas", tableMaxRows: 8, tableMaxCols: 26 });
const mappingInspect = await workbook.inspect({ kind: "table", range: "字段映射!A1:G16", include: "values,formulas", tableMaxRows: 16, tableMaxCols: 8 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" });
await fs.writeFile(path.join(taskDir, "summary-inspect.ndjson"), summaryInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "detail-inspect.ndjson"), detailInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "mapping-inspect.ndjson"), mappingInspect.ndjson, "utf8");
await fs.writeFile(path.join(taskDir, "formula-errors.ndjson"), errors.ndjson, "utf8");
for (const [sheetName, range, filename] of [
["导出说明", "A1:F22", "preview-summary.png"],
["完整数据", "A1:T18", "preview-detail.png"],
["字段映射", "A1:G28", "preview-mapping.png"],
]) {
const preview = await workbook.render({ sheetName, range, scale: 1.25, format: "png" });
await fs.writeFile(path.join(taskDir, filename), new Uint8Array(await preview.arrayBuffer()));
}
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
await xlsx.save(outputPath);
console.log(JSON.stringify({ outputPath, sheets: ["导出说明", "完整数据", "字段映射"], rows: detailRows.length, parsedFields: mappedFields.length, columns: detailHeaders.length }, null, 2));
@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import path from "node:path";
const baseUrl = process.env.GB32960_BASE_URL || "http://115.29.187.205:20200";
const vin = process.env.GB32960_VIN || "LB9A32A24R0LS1720";
const plate = process.env.GB32960_PLATE || "粤AGP4377";
const exportDate = process.env.GB32960_DATE || "2026-08-17";
const dateFrom = `${exportDate} 00:00:00`;
const dateTo = `${exportDate} 23:59:59`;
const limit = 500;
const outputPath = process.env.GB32960_DATA_PATH || new URL("./raw-frames.json", import.meta.url).pathname;
async function fetchPage(offset, includeTotal = false) {
const params = new URLSearchParams({
protocol: "GB32960",
vin,
dateFrom,
dateTo,
orderBy: "eventTime",
limit: String(limit),
offset: String(offset),
includeFields: "true",
includePayload: "true",
includeTotal: includeTotal ? "true" : "false",
});
const response = await fetch(`${baseUrl}/api/history/raw-frames?${params}`, {
headers: { "Accept-Encoding": "gzip" },
signal: AbortSignal.timeout(60000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
const first = await fetchPage(0, true);
const total = Number(first.total ?? 0);
const offsets = [];
for (let offset = limit; offset < total; offset += limit) offsets.push(offset);
const remaining = await Promise.all(offsets.map((offset) => fetchPage(offset)));
const items = [...(first.items ?? []), ...remaining.flatMap((page) => page.items ?? [])];
const frameIds = new Set(items.map((item) => item.frame_id));
const eventIds = new Set(items.map((item) => item.event_id));
const fieldKeys = [...new Set(items.flatMap((item) => Object.keys(item.parsed_fields ?? {})))].sort();
const messageCounts = Object.fromEntries(
[...items.reduce((map, item) => map.set(item.message_id_hex, (map.get(item.message_id_hex) ?? 0) + 1), new Map())]
.sort(([a], [b]) => a.localeCompare(b)),
);
if (items.length !== total) throw new Error(`row count mismatch: fetched=${items.length} total=${total}`);
if (frameIds.size !== items.length) throw new Error(`duplicate frame_id: unique=${frameIds.size} rows=${items.length}`);
if (eventIds.size !== items.length) throw new Error(`duplicate event_id: unique=${eventIds.size} rows=${items.length}`);
for (const item of items) {
if (item.protocol !== "GB32960" || item.vin !== vin) throw new Error(`scope mismatch at ${item.frame_id}`);
if (item.ts < dateFrom || item.ts > dateTo) throw new Error(`time out of range at ${item.frame_id}: ${item.ts}`);
}
const output = {
exportedAt: new Date().toISOString(),
source: `${baseUrl}/api/history/raw-frames`,
query: { plate, vin, protocol: "GB32960", dateFrom, dateTo, orderBy: "eventTime" },
verification: {
apiTotal: total,
fetchedRows: items.length,
uniqueFrameIds: frameIds.size,
uniqueEventIds: eventIds.size,
earliestTs: items.reduce((min, item) => !min || item.ts < min ? item.ts : min, ""),
latestTs: items.reduce((max, item) => !max || item.ts > max ? item.ts : max, ""),
parsedFieldCount: fieldKeys.length,
messageCounts,
},
fieldKeys,
items,
};
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, JSON.stringify(output), "utf8");
console.log(JSON.stringify({ outputPath, ...output.verification }, null, 2));
@@ -0,0 +1,15 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const workbookPath = process.env.GB32960_WORKBOOK_PATH || "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/gb32960-export-20260817-agp4377/粤AGP4377_2026-08-17_GB32960完整数据_中文字段.xlsx";
const lastDataRow = Number(process.env.GB32960_LAST_ROW || "1233");
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 3000 });
const summary = await workbook.inspect({ kind: "table", range: "导出说明!A3:B15", include: "values,formulas", tableMaxRows: 20, tableMaxCols: 3 });
const detail = await workbook.inspect({ kind: "table", range: "完整数据!A1:T6", include: "values,formulas", tableMaxRows: 6, tableMaxCols: 20 });
const tail = await workbook.inspect({ kind: "table", range: `完整数据!A${lastDataRow - 4}:F${lastDataRow}`, include: "values,formulas", tableMaxRows: 6, tableMaxCols: 6 });
const mapping = await workbook.inspect({ kind: "table", range: "字段映射!A1:G10", include: "values,formulas", tableMaxRows: 10, tableMaxCols: 7 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reopened workbook formula error scan" });
const output = { sheets: sheets.ndjson, summary: summary.ndjson, detail: detail.ndjson, tail: tail.ndjson, mapping: mapping.ndjson, errors: errors.ndjson };
await fs.writeFile(new URL("./reopen-verification.json", import.meta.url), JSON.stringify(output, null, 2), "utf8");
console.log(JSON.stringify({ workbookPath, sizeBytes: (await fs.stat(workbookPath)).size, output }, null, 2));
@@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const sourceDir =
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
const workDir =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
const names = (await fs.readdir(sourceDir))
.filter((name) => name.endsWith(".xlsx"))
.sort((a, b) => a.localeCompare(b, "zh-CN"));
const manifest = [];
for (const name of names) {
const filePath = path.join(sourceDir, name);
const input = await FileBlob.load(filePath);
const workbook = await SpreadsheetFile.importXlsx(input);
const sheet = workbook.worksheets.getItemAt(0);
const usedRange = sheet.getUsedRange(true);
const values = usedRange?.values ?? [];
const rowCount = values.length;
const colCount = values.reduce((max, row) => Math.max(max, row.length), 0);
const topRows = values.slice(0, 8).map((row) =>
row.slice(0, Math.min(colCount, 40)).map((value) => {
if (value instanceof Date) return value.toISOString();
return value ?? null;
}),
);
const preview = await workbook.render({
sheetName: sheet.name,
range: `A1:${columnName(Math.min(Math.max(colCount, 1), 40))}${Math.min(
Math.max(rowCount, 1),
15,
)}`,
scale: 1,
format: "png",
});
const previewName = `${String(manifest.length + 1).padStart(2, "0")}-${safeName(
name,
)}.png`;
await fs.writeFile(
path.join(workDir, previewName),
new Uint8Array(await preview.arrayBuffer()),
);
manifest.push({
name,
sheet: sheet.name,
rowCount,
colCount,
firstRow: topRows[0] ?? [],
topRows,
preview: previewName,
});
}
await fs.writeFile(
path.join(workDir, "source-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
console.log(
JSON.stringify(
manifest.map(({ name, sheet, rowCount, colCount, firstRow, preview }) => ({
name,
sheet,
rowCount,
colCount,
firstRow,
preview,
})),
null,
2,
),
);
function safeName(name) {
return name
.replace(/\.xlsx$/i, "")
.replace(/[^\p{L}\p{N}._-]+/gu, "_")
.slice(0, 100);
}
function columnName(index) {
let value = index;
let result = "";
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result || "A";
}
@@ -0,0 +1,378 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const sourceDir =
"/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026/07/27";
const workDir =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/gps-mileage-import-019fa1ac-db39-7933-bb0e-30dd63cc25bd";
const priorCsv =
"/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/g7-mileage-history-20260724/merged/G7车辆每日里程_20220101-20260720.csv";
const normalizedCsv = path.join(
workDir,
"G7_GPS车辆每日里程_20251231-20260630.csv",
);
const combinedCsv = path.join(
workDir,
"G7_GPS车辆每日里程_合并优先季度统计_20251231-20260630.csv",
);
const statisticCsv = path.join(
workDir,
"G7_GPS季度统计每日里程_20260101-20260630.csv",
);
const dailyNames = [
"智能管车_车辆里程日报-091555330.xlsx",
"智能管车_车辆里程日报-090938059.xlsx",
"智能管车_车辆里程日报-091002580.xlsx",
"智能管车_车辆里程日报-091020404.xlsx",
"智能管车_车辆里程日报-091030619.xlsx",
"智能管车_车辆里程日报-091046118.xlsx",
"智能管车_车辆里程日报-091058580.xlsx",
];
const statisticNames = [
"里程统计[天][2026-01-01至2026-03-31] (1).xlsx",
"里程统计[天][2026-01-01至2026-03-31].xlsx",
"里程统计[天][2026-04-01至2026-06-30] (1).xlsx",
"里程统计[天][2026-04-01至2026-06-30].xlsx",
];
const rows = [];
const newByKey = new Map();
const dailyFiles = [];
for (const name of dailyNames) {
const values = await readFirstSheetValues(path.join(sourceDir, name));
const headers = values[0] ?? [];
const runtimeIndex = headers.indexOf("运行时长");
assert(runtimeIndex > 3, `${name} 缺少运行时长列`);
const dateHeaders = headers.slice(3, runtimeIndex);
const dates = dateHeaders.map((header) => parseHeaderDate(header));
const seenPlates = new Set();
let statedTotalKm = 0;
let calculatedTotalKm = 0;
let mismatchRows = 0;
for (const row of values.slice(1)) {
const plate = cleanText(row?.[0]);
if (!plate) continue;
assert(!seenPlates.has(plate), `${name} 存在重复车牌 ${plate}`);
seenPlates.add(plate);
const stated = numberValue(row?.[2], 10_000_000);
const dailyValues = row
.slice(3, runtimeIndex)
.map((value) => numberValue(value));
const calculated = dailyValues.reduce((sum, value) => sum + value, 0);
statedTotalKm += stated;
calculatedTotalKm += calculated;
if (Math.abs(stated - calculated) > 0.011) mismatchRows += 1;
for (let index = 0; index < dates.length; index += 1) {
const date = dates[index];
const mileage = dailyValues[index];
const key = `${plate}|${date}`;
assert(!newByKey.has(key), `日报重复车辆日 ${key}`);
const normalized = { plate, date, mileage };
newByKey.set(key, normalized);
rows.push(normalized);
}
}
dailyFiles.push({
name,
vehicleCount: seenPlates.size,
dateFrom: dates[0],
dateTo: dates.at(-1),
dateCount: dates.length,
rowCount: seenPlates.size * dates.length,
statedTotalKm: round(statedTotalKm),
calculatedTotalKm: round(calculatedTotalKm),
mismatchRows,
});
}
rows.sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
normalizedCsv,
`\uFEFFplate,date,daily_mileage_km\n${rows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const statisticByKey = new Map();
const statisticFiles = [];
for (const name of statisticNames) {
const values = await readFirstSheetValues(path.join(sourceDir, name));
const headers = (values[1] ?? []).map(cleanText);
const plateIndex = headers.indexOf("车牌号码");
const methodIndex = headers.indexOf("里程计算方式");
const dateIndex = headers.indexOf("日期");
const mileageIndex = headers.indexOf("里程(km");
assert(
[plateIndex, methodIndex, dateIndex, mileageIndex].every((index) => index >= 0),
`${name} 表头不完整`,
);
const fileKeys = new Set();
const methods = new Set();
let totalKm = 0;
for (const row of values.slice(2)) {
const plate = cleanText(row?.[plateIndex]);
const date = isoDate(row?.[dateIndex]);
if (!plate || !date) continue;
const method = cleanText(row?.[methodIndex]);
const mileage = numberValue(row?.[mileageIndex]);
const key = `${plate}|${date}`;
assert(!fileKeys.has(key), `${name} 文件内重复车辆日 ${key}`);
assert(!statisticByKey.has(key), `季度统计跨文件重复车辆日 ${key}`);
fileKeys.add(key);
statisticByKey.set(key, { plate, date, mileage, method, name });
methods.add(method);
totalKm += mileage;
}
statisticFiles.push({
name,
rowCount: fileKeys.size,
vehicleCount: new Set([...fileKeys].map((key) => key.split("|")[0])).size,
dateCount: new Set([...fileKeys].map((key) => key.split("|")[1])).size,
methods: [...methods].sort(),
totalKm: round(totalKm),
});
}
let statisticMatched = 0;
let statisticMissingInDaily = 0;
let statisticChanged = 0;
let statisticAbsoluteDifferenceKm = 0;
let statisticMaxDifferenceKm = 0;
const statisticExamples = [];
for (const [key, statistic] of statisticByKey) {
const daily = newByKey.get(key);
if (!daily) {
statisticMissingInDaily += 1;
continue;
}
statisticMatched += 1;
const difference = daily.mileage - statistic.mileage;
const absolute = Math.abs(difference);
statisticAbsoluteDifferenceKm += absolute;
statisticMaxDifferenceKm = Math.max(statisticMaxDifferenceKm, absolute);
if (absolute > 0.011) {
statisticChanged += 1;
if (statisticExamples.length < 20) {
statisticExamples.push({
key,
dailyKm: daily.mileage,
statisticKm: statistic.mileage,
differenceKm: round(difference),
});
}
}
}
const statisticRows = [...statisticByKey.values()].sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
statisticCsv,
`\uFEFFplate,date,daily_mileage_km\n${statisticRows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const combinedByKey = new Map(newByKey);
for (const [key, statistic] of statisticByKey) {
combinedByKey.set(key, {
plate: statistic.plate,
date: statistic.date,
mileage: statistic.mileage,
});
}
const combinedRows = [...combinedByKey.values()].sort(
(a, b) =>
a.plate.localeCompare(b.plate, "zh-CN") || a.date.localeCompare(b.date),
);
await fs.writeFile(
combinedCsv,
`\uFEFFplate,date,daily_mileage_km\n${combinedRows
.map((row) => `${row.plate},${row.date},${formatNumber(row.mileage)}`)
.join("\n")}\n`,
"utf8",
);
const priorByKey = await loadPriorRange(
priorCsv,
"2025-12-31",
"2026-06-30",
);
let priorUnchanged = 0;
let priorChanged = 0;
let priorMissing = 0;
let priorExtra = 0;
let priorTotalDifferenceKm = 0;
let priorMaxDifferenceKm = 0;
const priorExamples = [];
for (const [key, row] of newByKey) {
const prior = priorByKey.get(key);
if (!prior) {
priorMissing += 1;
continue;
}
const difference = row.mileage - prior.mileage;
const absolute = Math.abs(difference);
priorTotalDifferenceKm += difference;
priorMaxDifferenceKm = Math.max(priorMaxDifferenceKm, absolute);
if (absolute <= 0.0005) {
priorUnchanged += 1;
} else {
priorChanged += 1;
if (priorExamples.length < 20) {
priorExamples.push({
key,
newKm: row.mileage,
priorKm: prior.mileage,
differenceKm: round(difference),
});
}
}
}
for (const key of priorByKey.keys()) {
if (!newByKey.has(key)) priorExtra += 1;
}
const result = {
normalizedCsv,
combinedCsv,
statisticCsv,
source: {
rowCount: rows.length,
vehicleCount: new Set(rows.map((row) => row.plate)).size,
dateCount: new Set(rows.map((row) => row.date)).size,
dateFrom: rows.reduce(
(min, row) => (!min || row.date < min ? row.date : min),
"",
),
dateTo: rows.reduce((max, row) => (row.date > max ? row.date : max), ""),
positiveRows: rows.filter((row) => row.mileage > 0).length,
zeroRows: rows.filter((row) => row.mileage === 0).length,
totalKm: round(rows.reduce((sum, row) => sum + row.mileage, 0)),
files: dailyFiles,
},
quarterlyReconciliation: {
rowCount: statisticByKey.size,
matchedRows: statisticMatched,
missingInDailyRows: statisticMissingInDaily,
changedRowsOver0011Km: statisticChanged,
absoluteDifferenceKm: round(statisticAbsoluteDifferenceKm),
maxDifferenceKm: round(statisticMaxDifferenceKm),
examples: statisticExamples,
files: statisticFiles,
},
combinedImport: {
rowCount: combinedRows.length,
vehicleCount: new Set(combinedRows.map((row) => row.plate)).size,
dateCount: new Set(combinedRows.map((row) => row.date)).size,
dateFrom: combinedRows.reduce(
(min, row) => (!min || row.date < min ? row.date : min),
"",
),
dateTo: combinedRows.reduce(
(max, row) => (row.date > max ? row.date : max),
"",
),
positiveRows: combinedRows.filter((row) => row.mileage > 0).length,
zeroRows: combinedRows.filter((row) => row.mileage === 0).length,
totalKm: round(
combinedRows.reduce((sum, row) => sum + row.mileage, 0),
),
precedence:
"季度里程统计(明确标注终端里程/经纬度)优先;无季度记录时使用车辆里程日报",
},
priorImportComparison: {
priorRangeRows: priorByKey.size,
unchangedRows: priorUnchanged,
changedRows: priorChanged,
missingInPriorRows: priorMissing,
extraPriorRows: priorExtra,
totalDifferenceKm: round(priorTotalDifferenceKm),
maxDifferenceKm: round(priorMaxDifferenceKm),
examples: priorExamples,
},
};
await fs.writeFile(
path.join(workDir, "normalization-manifest.json"),
`${JSON.stringify(result, null, 2)}\n`,
"utf8",
);
console.log(JSON.stringify(result, null, 2));
async function readFirstSheetValues(file) {
const input = await FileBlob.load(file);
const workbook = await SpreadsheetFile.importXlsx(input);
return workbook.worksheets.getItemAt(0).getUsedRange(true)?.values ?? [];
}
async function loadPriorRange(file, dateFrom, dateTo) {
const result = new Map();
const stream = fsSync.createReadStream(file, { encoding: "utf8" });
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
let first = true;
for await (const line of lines) {
if (first) {
first = false;
continue;
}
if (!line) continue;
const [plate, date, rawMileage] = line.split(",");
if (date < dateFrom || date > dateTo) continue;
result.set(`${plate}|${date}`, {
plate,
date,
mileage: numberValue(rawMileage),
});
}
return result;
}
function parseHeaderDate(value) {
const text = cleanText(value);
const match = text.match(/^(\d{2})月(\d{2})日$/);
assert(match, `无法解析日报日期表头 ${text}`);
const month = Number(match[1]);
const year = month === 12 ? 2025 : 2026;
return `${year}-${match[1]}-${match[2]}`;
}
function isoDate(value) {
if (value instanceof Date) return value.toISOString().slice(0, 10);
const text = cleanText(value);
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
}
function cleanText(value) {
return String(value ?? "").trim();
}
function numberValue(value, max = 2500) {
if (value === null || value === undefined || value === "") return 0;
const number = Number(String(value).replace(/,/g, ""));
assert(Number.isFinite(number) && number >= 0 && number <= max, `非法里程 ${value}`);
return number;
}
function formatNumber(value) {
if (Number.isInteger(value)) return String(value);
return String(Number(value.toFixed(3)));
}
function round(value) {
return Math.round((value + Number.EPSILON) * 1000) / 1000;
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
@@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
const base = 'http://115.29.187.205:20200';
const date = '2026-08-25';
const metrics = JSON.parse(await fs.readFile(new URL('./daily_metrics.json', import.meta.url), 'utf8'));
const candidates = metrics.items
.filter(x => String(x.platform_name || '').startsWith('现代') && Number(x.daily_mileage_km || 0) >= 10)
.sort((a, b) => Number(b.daily_mileage_km) - Number(a.daily_mileage_km));
async function one(x) {
const q = new URLSearchParams({
protocol: 'GB32960', vin: x.vin,
dateFrom: `${date} 00:00:00`, dateTo: `${date} 23:59:59`,
orderBy: 'eventTime', limit: '1', offset: '0', includeFields: 'true',
includePayload: 'false', includeTotal: 'true'
});
const res = await fetch(`${base}/api/history/raw-frames?${q}`);
if (!res.ok) throw new Error(`${x.vin}: ${res.status}`);
const body = await res.json();
const f = body.items?.[0]?.parsed_fields || {};
const required = [
'gb32960.fuel_cell.max_hydrogen_pressure_mpa',
'gb32960.fuel_cell.max_hydrogen_temperature_c',
'gb32960.vehicle.soc_percent',
'gb32960.vehicle.total_mileage_km',
'gb32960.vehicle.vehicle_status',
'gb32960.vehicle.charge_status'
];
return {...x, raw_total: Number(body.total || 0), first_has: required.filter(k => f[k] != null).length};
}
const out = [];
for (let i = 0; i < candidates.length; i += 12) {
const batch = candidates.slice(i, i + 12);
out.push(...await Promise.all(batch.map(one)));
process.stderr.write(`checked ${Math.min(i + 12, candidates.length)}\n`);
}
await fs.writeFile(new URL('./candidate_assessment.json', import.meta.url), JSON.stringify(out, null, 2));
const totals = out.map(x => x.raw_total).sort((a,b)=>a-b);
console.log(JSON.stringify({count: out.length, min: totals[0], median: totals[Math.floor(totals.length/2)], max: totals.at(-1), complete_first_frame: out.filter(x=>x.first_has===6).length, prefixes: Object.entries(Object.groupBy(out, x=>x.vin.slice(0,3))).map(([prefix, rows]) => ({prefix, count: rows.length}))}, null, 2));
@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import { Workbook, SpreadsheetFile } from "@oai/artifact-tool";
const root="/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
const inputDir=`${root}/tmp/hydrogen-audit-100-real/output-v4`;
const outputRoot=`${root}/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083`;
const packageDir=`${outputRoot}/100个真实车辆日氢耗核验资料包_V3`;
const outputPath=`${packageDir}/100个真实车辆日氢耗人工验算底稿_V3.xlsx`;
const previewDir=`${outputRoot}/preview-real-100-v3`;
const days=JSON.parse(await fs.readFile(`${inputDir}/daily_results.json`,"utf8"));
const C={navy:"#0B3A53",blue:"#0E7490",cyan:"#CDEEF4",pale:"#EAF6F8",white:"#FFFFFF",green:"#DCFCE7",greenText:"#166534",orange:"#FFEDD5",orangeText:"#9A3412",red:"#FEE2E2",redText:"#991B1B"};
const coeffA=[0.05888460,-0.06136111,-0.002650473,0.002731125,0.001802374,-0.001150707,0.00009588528,-0.0000001109040,0.0000000001264403],coeffB=[1.325,1.87,2.5,2.8,2.938,3.14,3.37,3.75,4.0],coeffC=[1,1,2,2,2.42,2.63,3,4,5];
function col(n){let s="";while(n){n--;s=String.fromCharCode(65+n%26)+s;n=Math.floor(n/26)}return s}
function title(sheet,address,text){const r=sheet.getRange(address);r.merge();r.values=[[text]];r.format={fill:C.navy,font:{bold:true,color:C.white,size:16},verticalAlignment:"center",horizontalAlignment:"left"};r.format.rowHeight=34}
function header(r){r.format={fill:C.blue,font:{bold:true,color:C.white},verticalAlignment:"center",horizontalAlignment:"center",wrapText:true,borders:{preset:"all",style:"thin",color:"#B8CDD5"}};r.format.rowHeight=34}
function body(r){r.format={verticalAlignment:"center",borders:{insideHorizontal:{style:"thin",color:"#DDE7EB"},bottom:{style:"thin",color:"#B8CDD5"}}}}
function widths(sheet,arr){arr.forEach((w,i)=>sheet.getRange(`${col(i+1)}:${col(i+1)}`).format.columnWidth=w)}
function val(v){return v===undefined||v===null?null:v}
function zFormula(p,t){return ["1",...Array.from({length:9},(_,i)=>`'参数与公式'!$B$${26+i}*POWER(100/(${t}+273.15),'参数与公式'!$C$${26+i})*POWER(${p},'参数与公式'!$D$${26+i})`)].join("+")}
function massFormula(p,t,v){return `${p}*1000*0.00201588*${v}/(8.314472*(${t}+273.15)*(${zFormula(p,t)}))`}
const wb=Workbook.create();wb.comments.setSelf({displayName:"车辆数据中台"});
const guide=wb.worksheets.add("核验说明"),params=wb.worksheets.add("参数与公式"),summary=wb.worksheets.add("100车辆日结果"),h2Detail=wb.worksheets.add("氢量计算明细"),operation=wb.worksheets.add("运行周期明细"),rawIndex=wb.worksheets.add("原始帧索引"),fields=wb.worksheets.add("原始字段说明");
for(const s of [guide,params,summary,h2Detail,operation,rawIndex,fields])s.showGridLines=false;
title(guide,"A1:H1","100个真实车辆日氢耗人工验算底稿(V3)");guide.getRange("A3:B3").values=[["项目","说明"]];header(guide.getRange("A3:B3"));
const statusCounts=Object.fromEntries(["OK","SUSPECT","NO_DATA"].map(k=>[k,days.filter(d=>d.stat.QualityStatus===k).length]));
const guideRows=[
["样本口径","2026-08-25,固定100个真实车辆日;4.5T普货41辆(380 L)、4.5T冷链59辆(520 L),动力电池均为21.04 kWh。"],
["原始数据","沿用原100个样本的232,291条GB/T 32960原始帧,未更换车辆、日期或报文;每车1个CSV.GZ,保留原始HEX及中文映射字段。"],
["有效分界点","取消上下电前后60秒窗口和首尾中位数,直接取满足车辆启动、字段完整、压力温度有效且不在外部充电/异常压力记录中的有效分界点。"],
["物理用氢量","无加氢:首个有效运行分界点剩余氢量-最后有效运行分界点剩余氢量;有加氢:按加氢事件切段后累计各段氢量下降。充电不切分物理氢量。"],
["充电识别","仅当充电状态=1且车辆状态=2时识别为外部充电;每次充电完成后建立新充电周期。"],
["纯电里程","每个充电周期若启动时为纯电,仅累计首次连续纯电前缀;混动连续3帧且相邻不超过30秒后,从首帧分界点锁定为混动,直至下一次充电。若周期从混动启动,则纯电里程为0。"],
["跨日状态","日界线只用于出报,不重置充电周期。若缺少日初已持久化状态,按已进入混动处理,不虚增日初纯电里程,并标记SUSPECT供复核。"],
["SOC修正","只使用各充电周期混动段的状态边界:ΔSOC=末SOC-初SOC;电等效氢=电池容量×ΔSOC÷100÷16。SOC上升为正并从用氢量扣除;SOC下降为负,扣除负值即补回电池放电能量。"],
["最终公式","修正耗氢量=物理用氢量-用电等效氢;混动里程=总里程-纯电里程;修正百公里氢耗=修正耗氢量÷混动里程×100。"],
["人工核对顺序","①在“100车辆日结果”看系统值与Excel复算差;②在“氢量计算明细”核对加氢分段及压力质量;③在“运行周期明细”核对纯电前缀和混动SOC;④按事件ID回查原始CSV。"],
["重算结果",`OK ${statusCounts.OK}辆日;SUSPECT ${statusCounts.SUSPECT}辆日;NO_DATA ${statusCounts.NO_DATA}辆日。SUSPECT不删除结果,保留原因和原始证据供人工判断。`],
["算法版本","PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3"]];
guide.getRange(`A4:B${3+guideRows.length}`).values=guideRows;body(guide.getRange(`A4:B${3+guideRows.length}`));guide.getRange(`B4:B${3+guideRows.length}`).format.wrapText=true;widths(guide,[24,112]);guide.freezePanes.freezeRows(3);
title(params,"A1:F1","系统参数、压力—质量公式与业务计算口径");params.getRange("A3:C3").values=[["参数","取值","说明"]];header(params.getRange("A3:C3"));
const pRows=[["4.5T普货储氢总容积(L)",380,"中台VIN参数表"],["4.5T冷链储氢总容积(L)",520,"中台VIN参数表"],["动力电池容量(kWh)",21.04,"两类4.5T车辆一致"],["氢气等效电量(kWh/kg)",16,"SOC能量折算"],["上电/下电时间窗口(s)",0,"直接使用有效分界点"],["加氢压升阈值(MPa)",3,"需持续300秒"],["加氢持续时间(s)",300,"5分钟"],["纯电异常压降(MPa)",5,"30分钟窗口"],["纯电异常窗口(s)",1800,"30分钟"],["混动确认连续帧数",3,"避免单帧运行模式抖动"],["混动确认相邻最大间隔(s)",30,"确认后回溯到首个混动帧"],["最低混动里程(km)",10,"不足标记SUSPECT"]];
params.getRange(`A4:C${3+pRows.length}`).values=pRows;body(params.getRange(`A4:C${3+pRows.length}`));params.getRange(`B4:B${3+pRows.length}`).format.numberFormat="0.000";
params.getRange("A18:F18").merge();params.getRange("A18:F18").values=[["核心公式"]];params.getRange("A18:F18").format={fill:C.cyan,font:{bold:true,color:C.navy}};
const formulas=["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]","剩余氢量 m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)","ΔSOC = SOC_end SOC_start;用电等效氢 = 电池容量 × ΔSOC ÷ 100 ÷ 16","修正耗氢量 = 物理用氢量 − 用电等效氢","混动里程 = 总里程 − 纯电里程;百公里氢耗 = 修正耗氢量 ÷ 混动里程 × 100"];
for(let i=0;i<formulas.length;i++){const row=19+i;params.getRange(`A${row}:F${row}`).merge();params.getRange(`A${row}:F${row}`).values=[[formulas[i]]];params.getRange(`A${row}:F${row}`).format={fill:i%2?C.white:C.pale,font:{color:C.navy,bold:i===3},wrapText:true}}
params.getRange("A25:D25").values=[["系数序号","aᵢ","bᵢ","cᵢ"]];header(params.getRange("A25:D25"));params.getRange("A26:D34").values=coeffA.map((a,i)=>[i+1,a,coeffB[i],coeffC[i]]);body(params.getRange("A26:D34"));params.getRange("B26:D34").format.numberFormat="0.0000000000";
wb.comments.addThread({cell:params.getRange("B4")},"普货按中台VIN车型参数取380 L,不与冷链520 L混用。");wb.comments.addThread({cell:params.getRange("B5")},"冷链按中台VIN车型参数取520 L。");wb.comments.addThread({cell:params.getRange("B6")},"4.5T普货和冷链动力电池容量均为21.04 kWh。");wb.comments.addThread({cell:params.getRange("B7")},"业务确认:1 kg氢气等效16 kWh,用于SOC能量折算。");widths(params,[32,18,58,15,15,15]);params.freezePanes.freezeRows(3);
const hHeaders=["样例","车牌","日期","VIN","车型","氢量段号","分段说明","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量(kg)","系统结束氢量(kg)","系统分段用氢(kg)","起始里程(km)","结束里程(km)","区间里程(km)","样本数","质量状态","起始氢量复算","结束氢量复算","起始质量差","结束质量差","分段用氢复算","用氢差值","复算结论"];
h2Detail.getRange(`A1:${col(hHeaders.length)}1`).values=[hHeaders];header(h2Detail.getRange(`A1:${col(hHeaders.length)}1`));const hRows=[];for(const d of days)for(const it of d.stat.HydrogenIntervals||[])hRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,it.index,it.qualityReason,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),it.sampleCount,it.qualityStatus]);
h2Detail.getRange(`A2:Y${hRows.length+1}`).values=hRows;for(let r=2;r<=hRows.length+1;r++){h2Detail.getRange(`Z${r}`).formulas=[[`=${massFormula(`N${r}`,`P${r}`,`M${r}`)}`]];h2Detail.getRange(`AA${r}`).formulas=[[`=${massFormula(`O${r}`,`Q${r}`,`M${r}`)}`]];h2Detail.getRange(`AB${r}`).formulas=[[`=Z${r}-R${r}`]];h2Detail.getRange(`AC${r}`).formulas=[[`=AA${r}-S${r}`]];h2Detail.getRange(`AD${r}`).formulas=[[`=Z${r}-AA${r}`]];h2Detail.getRange(`AE${r}`).formulas=[[`=AD${r}-T${r}`]];h2Detail.getRange(`AF${r}`).formulas=[[`=IF(MAX(ABS(AB${r}),ABS(AC${r}),ABS(AE${r}))<=0.002,"一致","复核")`]]}
body(h2Detail.getRange(`A2:AF${hRows.length+1}`));h2Detail.getRange(`M2:AE${hRows.length+1}`).format.numberFormat="0.000000";h2Detail.getRange(`Y2:Y${hRows.length+1}`).conditionalFormats.add("containsText",{text:"SUSPECT",format:{fill:C.orange,font:{color:C.orangeText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});h2Detail.getRange(`AF2:AF${hRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});h2Detail.tables.add(`A1:AF${hRows.length+1}`,true,"HydrogenEvidenceV3").style="TableStyleMedium2";h2Detail.freezePanes.freezeRows(1);h2Detail.freezePanes.freezeColumns(7);widths(h2Detail,[8,13,13,22,24,10,34,22,22,34,34,24,14,14,14,14,14,17,17,17,15,15,15,10,13,17,17,14,14,17,14,12]);
const oHeaders=["样例","车牌","日期","VIN","车型","充电周期号","区间类型","区间号","开始时间","结束时间","开始事件ID","结束事件ID","数据源","储氢容积(L)","电池容量(kWh)","起始压力(MPa)","结束压力(MPa)","起始温度(℃)","结束温度(℃)","系统起始氢量","系统结束氢量","区间物理氢量","起始SOC","结束SOC","系统ΔSOC(末-初)","系统电量变化(kWh)","系统电等效氢(kg)","系统段修正氢量","起始里程","结束里程","系统区间里程","系统段百公里","样本数","质量状态","分界说明","起始氢量复算","结束氢量复算","起始质量差","结束质量差","ΔSOC复算","电量变化复算","电等效氢复算","段修正氢量复算","里程复算","纯电里程贡献","混动电等效氢贡献","复算结论"];
operation.getRange(`A1:${col(oHeaders.length)}1`).values=[oHeaders];header(operation.getRange(`A1:${col(oHeaders.length)}1`));const oRows=[];for(const d of days){let cycleNo=0,previous="";for(const it of d.stat.Intervals||[]){if(cycleNo===0||it.type==="PURE_ELECTRIC"||previous==="MIXED")cycleNo++;oRows.push([d.candidate.sample_no,d.candidate.plate,d.candidate.stat_date,d.candidate.vin,d.candidate.model,cycleNo,it.type,it.index,it.startTime,it.endTime,it.startEventId,it.endEventId,it.source,d.candidate.tank_capacity_l,21.04,it.startPressureMpa,it.endPressureMpa,it.startTemperatureC,it.endTemperatureC,it.startMassKg,it.endMassKg,it.rawHydrogenConsumptionKg,val(it.startSocPercent),val(it.endSocPercent),val(it.batterySocDeltaPct),val(it.batteryEnergyChangeKWh),val(it.electricEquivalentHydrogenKg),val(it.correctedHydrogenConsumptionKg),val(it.startMileageKm),val(it.endMileageKm),val(it.mileageKm),val(it.consumptionKgPer100Km),it.sampleCount,it.qualityStatus,it.qualityReason]);previous=it.type}}
operation.getRange(`A2:AI${oRows.length+1}`).values=oRows;for(let r=2;r<=oRows.length+1;r++){operation.getRange(`AJ${r}`).formulas=[[`=${massFormula(`P${r}`,`R${r}`,`N${r}`)}`]];operation.getRange(`AK${r}`).formulas=[[`=${massFormula(`Q${r}`,`S${r}`,`N${r}`)}`]];operation.getRange(`AL${r}`).formulas=[[`=AJ${r}-T${r}`]];operation.getRange(`AM${r}`).formulas=[[`=AK${r}-U${r}`]];operation.getRange(`AN${r}`).formulas=[[`=IF(OR(W${r}="",X${r}=""),"",X${r}-W${r})`]];operation.getRange(`AO${r}`).formulas=[[`=IF(AN${r}="","",O${r}*AN${r}/100)`]];operation.getRange(`AP${r}`).formulas=[[`=IF(AO${r}="","",AO${r}/'参数与公式'!$B$7)`]];operation.getRange(`AQ${r}`).formulas=[[`=IF(G${r}="MIXED",V${r}-AP${r},"")`]];operation.getRange(`AR${r}`).formulas=[[`=IF(OR(AC${r}="",AD${r}=""),"",AD${r}-AC${r})`]];operation.getRange(`AS${r}`).formulas=[[`=IF(G${r}="PURE_ELECTRIC",AR${r},0)`]];operation.getRange(`AT${r}`).formulas=[[`=IF(G${r}="MIXED",AP${r},0)`]];operation.getRange(`AU${r}`).formulas=[[`=IF(MAX(ABS(AL${r}),ABS(AM${r}),ABS(AN${r}-Y${r}),ABS(AP${r}-AA${r}),ABS(AR${r}-AE${r}))<=0.002,"一致","复核")`]]}
body(operation.getRange(`A2:AU${oRows.length+1}`));operation.getRange(`N2:AT${oRows.length+1}`).format.numberFormat="0.000000";operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"PURE_ELECTRIC",format:{fill:C.cyan,font:{color:C.navy}}});operation.getRange(`G2:G${oRows.length+1}`).conditionalFormats.add("containsText",{text:"MIXED",format:{fill:C.green,font:{color:C.greenText}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"一致",format:{fill:C.green,font:{color:C.greenText,bold:true}}});operation.getRange(`AU2:AU${oRows.length+1}`).conditionalFormats.add("containsText",{text:"复核",format:{fill:C.red,font:{color:C.redText,bold:true}}});operation.tables.add(`A1:AU${oRows.length+1}`,true,"OperationCyclesV3").style="TableStyleMedium2";operation.freezePanes.freezeRows(1);operation.freezePanes.freezeColumns(8);widths(operation,[8,13,13,22,24,11,15,9,22,22,34,34,24,14,15,14,14,14,14,16,16,16,12,12,15,17,17,18,15,15,16,17,10,13,42,17,17,14,14,15,17,17,18,15,16,20,12]);
const sHeaders=["样例","日期","车牌","VIN","车型","储氢容积(L)","电池容量(kWh)","原统计日里程(km)","有效起始仪表里程","有效结束仪表里程","总里程复算","API原始帧","算法样本","氢量分段数","运行区间数","加氢次数","充电次数","异常记录","系统物理用氢","物理用氢复算","物理差值","系统ΔSOC","ΔSOC复算","系统电等效氢","电等效氢复算","等效氢差值","系统修正耗氢","修正耗氢复算","修正差值","系统纯电里程","纯电里程复算","系统混动里程","混动里程复算","混动差值","系统物理百公里","物理百公里复算","系统修正百公里","修正百公里复算","百公里差值","质量状态","质量原因","算法版本","原始文件","打开原始帧"];
summary.getRange(`A1:${col(sHeaders.length)}1`).values=[sHeaders];header(summary.getRange(`A1:${col(sHeaders.length)}1`));
const sRows=days.map(d=>{const s=d.stat,c=d.candidate;return[c.sample_no,c.stat_date,c.plate,c.vin,c.model,c.tank_capacity_l,21.04,c.daily_mileage_km,val(s.FirstObservation?.MileageKm),val(s.LastObservation?.MileageKm),null,d.apiRawFrameCount,d.algorithmSamples,(s.HydrogenIntervals||[]).length,(s.Intervals||[]).length,s.RefuelCount,s.ChargeCount,s.InvalidSegmentCount+s.AbnormalDropCount,s.ConsumptionKg,null,null,val(s.BatterySOCDeltaPct),null,val(s.ElectricEquivalentKg),null,null,val(s.CorrectedConsumptionKg),null,null,s.PureElectricMileageKm,null,s.MixedMileageKm,null,null,val(s.ConsumptionKgPer100Km),null,val(s.SOCBalancedKgPer100Km),null,null,s.QualityStatus,s.QualityReason,s.CalculationParameters?.algorithmVersion,d.rawArchiveFile,null]});
summary.getRange(`A2:AR${sRows.length+1}`).values=sRows;const hEnd=hRows.length+1,oEnd=oRows.length+1;
for(let r=2;r<=sRows.length+1;r++){
summary.getRange(`K${r}`).formulas=[[`=IF(OR(I${r}="",J${r}=""),"",J${r}-I${r})`]];
summary.getRange(`T${r}`).formulas=[[`=SUMIFS('氢量计算明细'!$AD$2:$AD$${hEnd},'氢量计算明细'!$D$2:$D$${hEnd},$D${r})`]];summary.getRange(`U${r}`).formulas=[[`=T${r}-S${r}`]];
summary.getRange(`W${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AN$2:$AN$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r},'运行周期明细'!$G$2:$G$${oEnd},"MIXED")`]];
summary.getRange(`Y${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AT$2:$AT$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];summary.getRange(`Z${r}`).formulas=[[`=Y${r}-X${r}`]];
summary.getRange(`AB${r}`).formulas=[[`=T${r}-Y${r}`]];summary.getRange(`AC${r}`).formulas=[[`=AB${r}-AA${r}`]];
summary.getRange(`AE${r}`).formulas=[[`=SUMIFS('运行周期明细'!$AS$2:$AS$${oEnd},'运行周期明细'!$D$2:$D$${oEnd},$D${r})`]];
summary.getRange(`AG${r}`).formulas=[[`=K${r}-AE${r}`]];summary.getRange(`AH${r}`).formulas=[[`=AG${r}-AF${r}`]];
summary.getRange(`AJ${r}`).formulas=[[`=IF(AG${r}>0,T${r}*100/AG${r},"")`]];summary.getRange(`AL${r}`).formulas=[[`=IF(AG${r}>0,AB${r}*100/AG${r},"")`]];summary.getRange(`AM${r}`).formulas=[[`=IF(OR(AK${r}="",AL${r}=""),"",AL${r}-AK${r})`]];
summary.getRange(`AR${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];
}
body(summary.getRange(`A2:AR${sRows.length+1}`));summary.getRange(`F2:AM${sRows.length+1}`).format.numberFormat="0.000000";
for(const [text,fill,font] of [["OK",C.green,C.greenText],["SUSPECT",C.orange,C.orangeText],["NO_DATA",C.red,C.redText]])summary.getRange(`AN2:AN${sRows.length+1}`).conditionalFormats.add("containsText",{text,format:{fill,font:{color:font,bold:true}}});
for(const c of ["U","Z","AC","AH","AM"]){summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"greaterThan",formula:0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}});summary.getRange(`${c}2:${c}${sRows.length+1}`).conditionalFormats.add("cellIs",{operator:"lessThan",formula:-0.002,format:{fill:C.red,font:{color:C.redText,bold:true}}})}
summary.tables.add(`A1:AR${sRows.length+1}`,true,"VehicleDayResultsV3").style="TableStyleMedium2";summary.freezePanes.freezeRows(1);summary.freezePanes.freezeColumns(5);widths(summary,[8,13,13,22,24,14,14,17,18,18,15,12,12,12,12,11,11,12,16,16,14,14,14,16,16,14,17,17,14,16,16,16,16,14,18,18,18,18,14,13,48,42,58,12]);
const rHeaders=["样例","车牌","VIN","日期","车型","储氢容积(L)","电池容量(kWh)","API原始帧数","唯一帧数","重复帧数","算法样本数","关键字段帧数","原始最早事件时间","原始最晚事件时间","原始文件","打开"];
rawIndex.getRange("A1:P1").values=[rHeaders];header(rawIndex.getRange("A1:P1"));const rRows=days.map(d=>[d.candidate.sample_no,d.candidate.plate,d.candidate.vin,d.candidate.stat_date,d.candidate.model,d.candidate.tank_capacity_l,21.04,d.apiRawFrameCount,d.uniqueFrameCount,d.duplicateFrameCount,d.algorithmSamples,d.criticalFieldRows,d.earliestEventTime,d.latestEventTime,d.rawArchiveFile,null]);rawIndex.getRange(`A2:P${rRows.length+1}`).values=rRows;for(let r=2;r<=rRows.length+1;r++)rawIndex.getRange(`P${r}`).formulas=[[`=HYPERLINK("${days[r-2].rawArchiveFile}","打开")`]];body(rawIndex.getRange(`A2:P${rRows.length+1}`));rawIndex.tables.add(`A1:P${rRows.length+1}`,true,"RawFrameManifestV3").style="TableStyleMedium2";rawIndex.freezePanes.freezeRows(1);rawIndex.freezePanes.freezeColumns(5);widths(rawIndex,[8,13,22,13,24,14,14,14,12,12,14,15,21,21,58,12]);
title(fields,"A1:D1","原始帧字段及人工核验用途");fields.getRange("A3:D3").values=[["中文字段","GB/T 32960解析键/原始属性","用途","校验规则/枚举"]];header(fields.getRange("A3:D3"));
const fRows=[["原始报文HEX","raw_hex","回查原始帧","完整保留,不参与公式"],["事件ID","event_id","连接各明细起止事件","按ID在对应CSV查找"],["事件时间","event_time","确定自然日及排序","以事件时间为准"],["最高氢压(MPa)","gb32960.fuel_cell.max_hydrogen_pressure_mpa","压力—质量换算","0<P≤70"],["最高氢温(℃)","gb32960.fuel_cell.max_hydrogen_temperature_c","压力—质量换算","T>-40"],["电池SOC(%)","gb32960.vehicle.soc_percent","混动边界SOC修正","0100;只取相关状态边界"],["仪表总里程(km)","gb32960.vehicle.total_mileage_km","总/纯电/混动里程","分界点末值-初值"],["车辆状态","gb32960.vehicle.vehicle_status","启动及充电联合判断","1=启动,2=熄火,其他按国标保留"],["充电状态","gb32960.vehicle.charge_status","外部充电联合判断","1=停车充电,2=行驶充电,3=未充电,4=充电完成"],["运行模式","gb32960.vehicle.running_mode","纯电前缀/混动锁定","1=纯电,2=混动,3=燃油,254=异常,255=无效"],["燃料电池工作状态","gb32960.gd_fc_stack.engine_work_state","辅助确认纯电/混动","2=工作;0/1=未工作/停机"],["系统压力换算质量(kg)","工作底稿复算列","氢量端点","按VIN容积+NIST公式"],["计算角色/排除原因","工作底稿标注","定位参与/排除状态","有效分界点、外部充电、异常或字段缺失"]];
fields.getRange(`A4:D${3+fRows.length}`).values=fRows;body(fields.getRange(`A4:D${3+fRows.length}`));fields.getRange(`A4:D${3+fRows.length}`).format.wrapText=true;widths(fields,[28,62,40,54]);fields.freezePanes.freezeRows(3);
await fs.mkdir(packageDir,{recursive:true});
const checks={};for(const [name,range] of [["核验说明","A1:H15"],["参数与公式","A1:F34"],["100车辆日结果","A1:AR12"],["氢量计算明细","A1:AF15"],["运行周期明细","A1:AU15"],["原始帧索引","A1:P15"],["原始字段说明","A1:D16"]])checks[name]=(await wb.inspect({kind:"table",sheetId:name,range,include:"values,formulas",tableMaxRows:18,tableMaxCols:50,maxChars:18000})).ndjson;
const formulaErrors=await wb.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:10000});
const xlsx=await SpreadsheetFile.exportXlsx(wb);await xlsx.save(outputPath);
await fs.rm(previewDir,{recursive:true,force:true});await fs.mkdir(previewDir,{recursive:true});const renderRanges={"核验说明":"A1:H15","参数与公式":"A1:F34","100车辆日结果":"A1:AR14","氢量计算明细":"A1:AF14","运行周期明细":"A1:AU14","原始帧索引":"A1:P14","原始字段说明":"A1:D16"};for(const [name,range] of Object.entries(renderRanges)){const image=await wb.render({sheetName:name,range,scale:1,format:"png"});await fs.writeFile(`${previewDir}/${name}.png`,new Uint8Array(await image.arrayBuffer()))}
await fs.cp(`${inputDir}/raw`,`${packageDir}/raw`,{recursive:true,force:true});for(const name of ["daily_results.json","raw_manifest.csv","selected_vehicles.json"])await fs.copyFile(`${inputDir}/${name}`,`${packageDir}/${name}`);
await fs.writeFile(`${outputRoot}/verification-real-100-v3.json`,JSON.stringify({outputPath,checks,formulaErrors:formulaErrors.ndjson,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length},null,2));console.log(JSON.stringify({outputPath,days:days.length,hydrogenIntervals:hRows.length,operationIntervals:oRows.length,previewDir,formulaErrors:formulaErrors.ndjson},null,2));
@@ -0,0 +1,10 @@
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const path = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/100个真实车辆日氢耗核验资料包_V3/100个真实车辆日氢耗人工验算底稿_V3.xlsx";
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(path));
for (const [sheetId, range] of [["100车辆日结果", "A53:AR53"], ["氢量计算明细", "A1:AF4"], ["运行周期明细", "A1:AU8"]]) {
const result = await workbook.inspect({kind:"table", sheetId, range, include:"values,formulas", tableMaxRows:10, tableMaxCols:50, maxChars:16000});
console.log(result.ndjson);
}
const errors = await workbook.inspect({kind:"match",searchTerm:"#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A",options:{useRegex:true,maxResults:300},summary:"final formula error scan",maxChars:4000});
console.log(errors.ndjson);
@@ -0,0 +1,46 @@
import fs from "node:fs/promises";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath = "/Users/lingniu/Downloads/氢气计算公式.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/hydrogen-formula-analysis-20260826/preview";
await fs.mkdir(outputDir, { recursive: true });
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(inputPath));
const overview = await workbook.inspect({
kind: "workbook,sheet,table",
include: "id,name,values,formulas",
maxChars: 12000,
tableMaxRows: 30,
tableMaxCols: 20,
tableMaxCellChars: 300,
});
console.log("OVERVIEW");
console.log(overview.ndjson);
const sheets = (await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 6000 })).ndjson
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
.filter((item) => item.kind === "sheet");
for (let index = 0; index < sheets.length; index += 1) {
const sheet = sheets[index];
const region = await workbook.inspect({
kind: "region,formula,computedStyle",
sheetId: sheet.name,
range: "A1:Z80",
include: "values,formulas",
maxChars: 30000,
tableMaxRows: 80,
tableMaxCols: 26,
tableMaxCellChars: 500,
options: { maxResults: 300 },
});
console.log(`SHEET ${sheet.name}`);
console.log(region.ndjson);
const rendered = await workbook.render({ sheetName: sheet.name, autoCrop: "all", scale: 2, format: "png" });
const safeName = String(sheet.name).replace(/[\\/:*?"<>|]/g, "_");
await fs.writeFile(`${outputDir}/${String(index + 1).padStart(2, "0")}-${safeName}.png`, new Uint8Array(await rendered.arrayBuffer()));
}
console.log(JSON.stringify({ inputPath, outputDir, sheetCount: sheets.length }));
@@ -0,0 +1,496 @@
import fs from "node:fs/promises";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
const outputPath = `${outputDir}/氢耗算法120组人工验算底稿.xlsx`;
const previewDir = `${outputDir}/preview`;
const colors = {
navy: "#0B3A53",
blue: "#0E7490",
cyan: "#CDEEF4",
pale: "#EAF6F8",
green: "#DCFCE7",
greenText: "#166534",
orange: "#FFEDD5",
orangeText: "#9A3412",
red: "#FEE2E2",
redText: "#991B1B",
gray: "#E5E7EB",
grayText: "#475569",
white: "#FFFFFF",
};
const coeffA = [0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403];
const coeffB = [1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0];
const coeffC = [1, 1, 2, 2, 2.42, 2.63, 3, 4, 5];
function massKg(pressureMPa, temperatureC, volumeLiter) {
const temperatureK = temperatureC + 273.15;
let z = 1;
for (let i = 0; i < coeffA.length; i++) {
z += coeffA[i] * Math.pow(100 / temperatureK, coeffB[i]) * Math.pow(pressureMPa, coeffC[i]);
}
return pressureMPa * 1000 * 0.00201588 * volumeLiter / (8.314472 * temperatureK * z);
}
function excelCol(index) {
let result = "";
let value = index;
while (value > 0) {
value--;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
function styleTitle(sheet, range, title) {
range.merge();
range.values = [[title]];
range.format = {
fill: colors.navy,
font: { bold: true, color: colors.white },
verticalAlignment: "center",
horizontalAlignment: "left",
};
range.format.rowHeight = 32;
}
function styleHeader(range) {
range.format = {
fill: colors.blue,
font: { bold: true, color: colors.white },
verticalAlignment: "center",
horizontalAlignment: "center",
wrapText: true,
borders: { preset: "all", style: "thin", color: "#B8CDD5" },
};
range.format.rowHeight = 30;
}
function styleBody(range) {
range.format = {
verticalAlignment: "center",
borders: {
insideHorizontal: { style: "thin", color: "#DDE7EB" },
bottom: { style: "thin", color: "#B8CDD5" },
},
};
}
function setWidths(sheet, widths) {
widths.forEach((width, index) => {
sheet.getRange(`${excelCol(index + 1)}:${excelCol(index + 1)}`).format.columnWidth = width;
});
}
const workbook = Workbook.create();
workbook.comments.setSelf({ displayName: "User" });
const guide = workbook.worksheets.add("验算说明");
const params = workbook.worksheets.add("参数与系数");
const grid = workbook.worksheets.add("120组压力质量验算");
const raw = workbook.worksheets.add("原始报文120条");
const detail = workbook.worksheets.add("样本计算明细");
const summary = workbook.worksheets.add("每日与区间汇总");
for (const sheet of [guide, params, grid, raw, detail, summary]) sheet.showGridLines = false;
// 参数与NIST系数
styleTitle(params, params.getRange("A1:F1"), "氢耗算法参数与NIST实气压缩因子系数");
params.getRange("A3:C3").values = [["参数", "取值", "说明"]];
styleHeader(params.getRange("A3:C3"));
const parameterRows = [
["储氢系统总容积(L)", 520, "验算样本使用,可替换为车型确认值"],
["动力电池容量(kWh)", 21.04, "4.5T示例;必须使用售后技术部确认值"],
["1kg氢气等效电量(kWh/kg)", 16, "当前业务折算参数"],
["上电稳定等待(s)", 60, "上电后60秒内样本不作为计算端点"],
["下电提前窗口(s)", 60, "下电前60秒样本不作为计算端点"],
["加氢压力上升阈值(MPa)", 3, "并需持续达到保持时间"],
["加氢保持时间(s)", 300, "5分钟"],
["纯电异常压降阈值(MPa)", 5, "30分钟内超过该值判异常"],
["纯电异常窗口(s)", 1800, "30分钟"],
["端点中位样本数", 5, "区间首尾各5条"],
["最少有效样本数", 10, "不足10条不形成有效区间"],
["样本间隔(s)", 10, "本验算原始数据间隔"],
];
params.getRange(`A4:C${3 + parameterRows.length}`).values = parameterRows;
styleBody(params.getRange(`A4:C${3 + parameterRows.length}`));
params.getRange(`B4:B${3 + parameterRows.length}`).format.numberFormat = "0.000";
params.getRange("A18:D18").values = [["系数序号", "aᵢ", "bᵢ", "cᵢ"]];
styleHeader(params.getRange("A18:D18"));
params.getRange("A19:D27").values = coeffA.map((a, index) => [index + 1, a, coeffB[index], coeffC[index]]);
styleBody(params.getRange("A19:D27"));
params.getRange("B19:D27").format.numberFormat = "0.0000000000";
params.getRange("A29:F33").values = [
["压力—质量公式", null, null, null, null, null],
["Z = 1 + Σ[aᵢ × (100/Tₖ)^bᵢ × P^cᵢ]", null, null, null, null, null],
["m = P × 1000 × 0.00201588 × V ÷ (8.314472 × Tₖ × Z)", null, null, null, null, null],
["SOC平衡公式", null, null, null, null, null],
["m_SOC = m_H₂ + C_battery × (SOC_start SOC_end) ÷ 100 ÷ 16", null, null, null, null, null],
];
for (const row of [29, 30, 31, 32, 33]) params.getRange(`A${row}:F${row}`).merge();
params.getRange("A29:F29").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
params.getRange("A32:F32").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
params.getRange("A30:F31").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
params.getRange("A33:F33").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true };
setWidths(params, [28, 18, 52, 16, 16, 16]);
params.freezePanes.freezeRows(3);
// 120组独立压力—质量验算
const gridHeaders = ["序号", "压力P(MPa)", "温度T(℃)", "容积V(L)", "绝对温度Tₖ(K)", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "压缩因子Z", "分子", "分母", "Excel复算质量(kg)", "系统计算质量(kg)", "绝对差值(kg)", "核验结果"];
grid.getRange(`A1:${excelCol(gridHeaders.length)}1`).values = [gridHeaders];
styleHeader(grid.getRange(`A1:${excelCol(gridHeaders.length)}1`));
const pressures = [1, 3, 5, 8, 10, 12, 15, 18, 21, 25, 30, 35];
const temperatures = [-20, -10, 0, 10, 20, 30, 40, 50, 60, 70];
const gridInputs = [];
const gridSystem = [];
let gridIndex = 1;
for (const pressure of pressures) {
for (const temperature of temperatures) {
gridInputs.push([gridIndex++, pressure, temperature, 520]);
gridSystem.push(massKg(pressure, temperature, 520));
}
}
grid.getRange("A2:D121").values = gridInputs;
for (let row = 2; row <= 121; row++) {
grid.getRange(`E${row}`).formulas = [[`=C${row}+273.15`]];
for (let term = 0; term < 9; term++) {
const col = excelCol(6 + term);
const coeffRow = 19 + term;
grid.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$E${row},'参数与系数'!$C$${coeffRow})*POWER($B${row},'参数与系数'!$D$${coeffRow})`]];
}
grid.getRange(`O${row}`).formulas = [[`=1+SUM(F${row}:N${row})`]];
grid.getRange(`P${row}`).formulas = [[`=B${row}*1000*0.00201588*D${row}`]];
grid.getRange(`Q${row}`).formulas = [[`=8.314472*E${row}*O${row}`]];
grid.getRange(`R${row}`).formulas = [[`=P${row}/Q${row}`]];
grid.getRange(`S${row}`).values = [[gridSystem[row - 2]]];
grid.getRange(`T${row}`).formulas = [[`=ABS(R${row}-S${row})`]];
grid.getRange(`U${row}`).formulas = [[`=IF(T${row}<=0.0000000001,"通过","不通过")`]];
}
styleBody(grid.getRange("A2:U121"));
grid.getRange("B2:D121").format.numberFormat = "0.000";
grid.getRange("E2:Q121").format.numberFormat = "0.0000000000";
grid.getRange("R2:S121").format.numberFormat = "0.000000";
grid.getRange("T2:T121").format.numberFormat = "0.000000000000";
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
grid.getRange("U2:U121").conditionalFormats.add("containsText", { text: "不通过", format: { fill: colors.red, font: { color: colors.redText, bold: true } } });
grid.tables.add("A1:U121", true, "NISTValidation120").style = "TableStyleMedium2";
grid.freezePanes.freezeRows(1);
grid.freezePanes.freezeColumns(5);
setWidths(grid, [8, 12, 12, 12, 15, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 16, 16, 18, 18, 18, 12]);
// 构造120条可人工核验的原始GB/T 32960解析字段样本。
const rawHeaders = ["序号", "报文ID", "上报时间", "数据源", "车辆状态", "充电状态", "运行模式", "燃料电池工作", "氢压(MPa)", "氢温(℃)", "储氢容积(L)", "电SOC(%)", "仪表总里程(km)", "原始解析字段JSON"];
raw.getRange("A1:N1").values = [rawHeaders];
styleHeader(raw.getRange("A1:N1"));
const rawRows = [];
const systemMasses = [];
const baseTime = new Date(Date.UTC(2026, 7, 26, 0, 0, 0));
for (let index = 0; index < 120; index++) {
const eventId = `audit-32960-${String(index + 1).padStart(3, "0")}`;
const observedAt = new Date(baseTime.getTime() + index * 10_000);
let vehicleState = 1;
let chargeState = 3;
let runningMode = 2;
let fuelCellActive = 1;
let pressure;
let soc;
let mileage;
if (index < 40) {
pressure = 30 - index * 0.04;
soc = 80 - index * 0.04;
mileage = 1000 + index * 0.08;
} else if (index < 60) {
vehicleState = 2;
chargeState = 1;
runningMode = 0;
fuelCellActive = 0;
pressure = 28.4 - (index - 40) * 0.002;
soc = 78.4 + (index - 40) * (11.6 / 19);
mileage = 1003.2;
} else if (index < 80) {
chargeState = index === 60 ? 4 : 3;
runningMode = 1;
fuelCellActive = 0;
pressure = 28.36 - (index - 60) * 0.005;
soc = 90 - (index - 60) * 0.15;
mileage = 1003.2 + (index - 60) * 0.1;
} else {
pressure = 28.26 - (index - 80) * 0.06;
soc = 87 - (index - 80) * 0.04;
mileage = 1005.2 + (index - 80) * 0.12;
if (index === 119) vehicleState = 2;
}
const temperature = 30 + Math.sin(index / 10);
const mass = massKg(pressure, temperature, 520);
systemMasses.push(mass);
const rawJson = JSON.stringify({
event_id: eventId,
vehicle_state: vehicleState,
charge_status: chargeState,
running_mode: runningMode,
fuel_cell_active: fuelCellActive,
hydrogen_max_pressure_mpa: Number(pressure.toFixed(6)),
hydrogen_max_temperature_c: Number(temperature.toFixed(6)),
soc_percent: Number(soc.toFixed(6)),
total_mileage_km: Number(mileage.toFixed(6)),
});
rawRows.push([index + 1, eventId, observedAt, "GB32960-audit-sample", vehicleState, chargeState, runningMode, fuelCellActive, pressure, temperature, 520, soc, mileage, rawJson]);
}
raw.getRange("A2:N121").values = rawRows;
styleBody(raw.getRange("A2:N121"));
raw.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
raw.getRange("I2:M121").format.numberFormat = "0.000000";
raw.getRange("N2:N121").format.wrapText = false;
raw.getRange("A123:N126").values = [
["人工核验说明", null, null, null, null, null, null, null, null, null, null, null, null, null],
["本页为验算构造的原始解析字段样本,不是生产车辆真实报文。生产复核时应按相同字段结构替换为中台导出的真实32960数据。", null, null, null, null, null, null, null, null, null, null, null, null, null],
["车辆状态:1=启动/运行,2=熄火;充电状态:1=停车充电,3=未充电,4=充电完成;运行模式:1=纯电,2=混动。", null, null, null, null, null, null, null, null, null, null, null, null, null],
["停车充电需由车辆状态与充电状态联合判断;充电区间不计算氢耗,充电完成重新建立SOC、压力、温度和里程基线。", null, null, null, null, null, null, null, null, null, null, null, null, null],
];
for (let row = 123; row <= 126; row++) raw.getRange(`A${row}:N${row}`).merge();
raw.getRange("A123:N123").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
raw.getRange("A124:N126").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
raw.tables.add("A1:N121", true, "RawTelemetry120").style = "TableStyleMedium2";
raw.freezePanes.freezeRows(1);
raw.freezePanes.freezeColumns(3);
setWidths(raw, [8, 24, 20, 24, 10, 10, 10, 14, 12, 12, 14, 12, 18, 90]);
// 逐条计算明细:全部中间状态和公式均保留。
const detailHeaders = ["序号", "报文ID", "上报时间", "压力P", "温度T", "容积V", "Tₖ", ...Array.from({ length: 9 }, (_, i) => `Z分项${i + 1}`), "Z", "分子", "分母", "Excel复算质量", "系统质量", "差值", "车辆状态", "充电状态", "运行模式", "燃料电池", "SOC", "仪表里程", "阶段", "上电60s通过", "下电60s通过", "停车充电", "状态组合有效", "参与计算", "排除原因", "区间类型", "系统区间号", "端点角色"];
detail.getRange(`A1:${excelCol(detailHeaders.length)}1`).values = [detailHeaders];
styleHeader(detail.getRange(`A1:${excelCol(detailHeaders.length)}1`));
for (let row = 2; row <= 121; row++) {
const rawRow = row;
const index = row - 2;
detail.getRange(`A${row}:F${row}`).formulas = [[
`='原始报文120条'!A${rawRow}`,
`='原始报文120条'!B${rawRow}`,
`='原始报文120条'!C${rawRow}`,
`='原始报文120条'!I${rawRow}`,
`='原始报文120条'!J${rawRow}`,
`='原始报文120条'!K${rawRow}`,
]];
detail.getRange(`G${row}`).formulas = [[`=E${row}+273.15`]];
for (let term = 0; term < 9; term++) {
const col = excelCol(8 + term);
const coeffRow = 19 + term;
detail.getRange(`${col}${row}`).formulas = [[`='参数与系数'!$B$${coeffRow}*POWER(100/$G${row},'参数与系数'!$C$${coeffRow})*POWER($D${row},'参数与系数'!$D$${coeffRow})`]];
}
detail.getRange(`Q${row}`).formulas = [[`=1+SUM(H${row}:P${row})`]];
detail.getRange(`R${row}`).formulas = [[`=D${row}*1000*0.00201588*F${row}`]];
detail.getRange(`S${row}`).formulas = [[`=8.314472*G${row}*Q${row}`]];
detail.getRange(`T${row}`).formulas = [[`=R${row}/S${row}`]];
detail.getRange(`U${row}`).values = [[systemMasses[index]]];
detail.getRange(`V${row}`).formulas = [[`=ABS(T${row}-U${row})`]];
detail.getRange(`W${row}:AB${row}`).formulas = [[
`='原始报文120条'!E${rawRow}`,
`='原始报文120条'!F${rawRow}`,
`='原始报文120条'!G${rawRow}`,
`='原始报文120条'!H${rawRow}`,
`='原始报文120条'!L${rawRow}`,
`='原始报文120条'!M${rawRow}`,
]];
const phase = index < 40 ? "充电前运行" : index < 60 ? "停车充电" : index < 80 ? "充电后纯电" : "充电后混动";
detail.getRange(`AC${row}`).values = [[phase]];
detail.getRange(`AD${row}`).formulas = [[`=IF(OR(C${row}<'原始报文120条'!$C$2+TIME(0,0,'参数与系数'!$B$7),AND(C${row}>='原始报文120条'!$C$62,C${row}<'原始报文120条'!$C$62+TIME(0,0,'参数与系数'!$B$7))),"否","是")`]];
detail.getRange(`AE${row}`).formulas = [[`=IF(OR(AND(C${row}>'原始报文120条'!$C$42-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$42),AND(C${row}>'原始报文120条'!$C$121-TIME(0,0,'参数与系数'!$B$8),C${row}<='原始报文120条'!$C$121)),"否","是")`]];
detail.getRange(`AF${row}`).formulas = [[`=IF(AND(W${row}=2,X${row}=1),"是","否")`]];
detail.getRange(`AG${row}`).formulas = [[`=IF(OR(AND(Y${row}=1,Z${row}=0),AND(Y${row}=2,Z${row}=1)),"是","否")`]];
detail.getRange(`AH${row}`).formulas = [[`=IF(AND(AD${row}="是",AE${row}="是",AF${row}="否",AG${row}="是",W${row}=1),"是","否")`]];
detail.getRange(`AI${row}`).formulas = [[`=IF(AH${row}="是","",IF(AF${row}="是","停车充电区间",IF(AD${row}="否","上电后60秒稳定窗口",IF(AE${row}="否","下电前60秒稳定窗口",IF(W${row}<>1,"车辆非运行状态","运行模式与燃料电池状态不匹配")))))`]];
detail.getRange(`AJ${row}`).formulas = [[`=IF(AH${row}<>"是","",IF(AND(Y${row}=1,Z${row}=0),"PURE_ELECTRIC",IF(AND(Y${row}=2,Z${row}=1),"MIXED","")))`]];
const segment = index >= 6 && index <= 34 ? 1 : index >= 66 && index <= 79 ? 2 : index >= 80 && index <= 113 ? 3 : null;
detail.getRange(`AK${row}`).values = [[segment]];
let endpointRole = "";
if (index >= 6 && index <= 10) endpointRole = `区间1起始候选${index - 5}`;
else if (index >= 30 && index <= 34) endpointRole = `区间1结束候选${index - 29}`;
else if (index >= 66 && index <= 70) endpointRole = `区间2起始候选${index - 65}`;
else if (index >= 75 && index <= 79) endpointRole = `区间2结束候选${index - 74}`;
else if (index >= 80 && index <= 84) endpointRole = `区间3起始候选${index - 79}`;
else if (index >= 109 && index <= 113) endpointRole = `区间3结束候选${index - 108}`;
detail.getRange(`AL${row}`).values = [[endpointRole]];
}
styleBody(detail.getRange(`A2:AL121`));
detail.getRange("C2:C121").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
detail.getRange("D2:V121").format.numberFormat = "0.000000";
detail.getRange("AA2:AB121").format.numberFormat = "0.000000";
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
detail.getRange("AH2:AH121").conditionalFormats.add("containsText", { text: "否", format: { fill: colors.gray, font: { color: colors.grayText } } });
detail.getRange("AF2:AF121").conditionalFormats.add("containsText", { text: "是", format: { fill: colors.orange, font: { color: colors.orangeText, bold: true } } });
detail.getRange("AL2:AL121").conditionalFormats.add("notContainsBlanks", { format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
detail.tables.add("A1:AL121", true, "TelemetryCalculationDetail").style = "TableStyleMedium2";
detail.freezePanes.freezeRows(1);
detail.freezePanes.freezeColumns(3);
setWidths(detail, [8, 24, 20, 11, 11, 11, 12, ...Array(9).fill(13), 12, 14, 14, 16, 16, 16, 10, 10, 10, 12, 11, 16, 16, 13, 13, 12, 14, 12, 28, 16, 12, 18]);
// 每日与区间汇总,公式均追溯至逐条明细。
styleTitle(summary, summary.getRange("A1:AB1"), "120条原始报文:每日氢耗与区间计算汇总");
summary.getRange("A3:H3").values = [["统计日期", "原始样本数", "参与计算样本", "停车充电样本", "有效区间", "混动区间", "纯电区间", "质量状态"]];
styleHeader(summary.getRange("A3:H3"));
summary.getRange("A4").values = [["2026-08-26"]];
summary.getRange("B4:H4").formulas = [[
`=COUNTA('原始报文120条'!$B$2:$B$121)`,
`=COUNTIF('样本计算明细'!$AH$2:$AH$121,"是")`,
`=COUNTIF('样本计算明细'!$AF$2:$AF$121,"是")`,
`=COUNTA($A$9:$A$11)`,
`=COUNTIF($B$9:$B$11,"MIXED")`,
`=COUNTIF($B$9:$B$11,"PURE_ELECTRIC")`,
`=IF(AND(B4=120,C4>=10,F4>=1),"OK","SUSPECT")`,
]];
styleBody(summary.getRange("A4:H4"));
summary.getRange("H4").conditionalFormats.add("containsText", { text: "OK", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
summary.getRange("J3:Q3").values = [["物理耗氢(kg)", "SOC差值(百分点)", "电池净放电(kWh)", "电量折氢(kg)", "SOC平衡氢耗(kg)", "混动里程(km)", "物理百公里氢耗", "SOC平衡百公里氢耗"]];
styleHeader(summary.getRange("J3:Q3"));
summary.getRange("J4:Q4").formulas = [[
`=SUMIF($B$9:$B$11,"MIXED",$P$9:$P$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$R$9:$R$11)-SUMIF($B$9:$B$11,"MIXED",$Q$9:$Q$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$T$9:$T$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$U$9:$U$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$V$9:$V$11)`,
`=SUMIF($B$9:$B$11,"MIXED",$Y$9:$Y$11)`,
`=IF(O4>0,J4/O4*100,"")`,
`=IF(O4>0,N4/O4*100,"")`,
]];
styleBody(summary.getRange("J4:Q4"));
summary.getRange("J4:Q4").format.numberFormat = "0.000000";
summary.getRange("A7:AB7").values = [["区间", "类型", "有效样本", "起始候选行", "结束候选行", "起始报文ID", "结束报文ID", "起始时间", "结束时间", "起始压力", "结束压力", "起始温度", "结束温度", "起始质量", "结束质量", "物理耗氢", "起始SOC", "结束SOC", "SOC差值", "电池净放电", "电量折氢", "SOC平衡氢耗", "起始里程", "结束里程", "区间里程", "物理百公里", "SOC平衡百公里", "是否计入日氢耗"]];
styleHeader(summary.getRange("A7:AB7"));
const intervalSpecs = [
{ row: 9, index: 1, type: "MIXED", sampleCount: 29, startRows: [8, 12], endRows: [32, 36] },
{ row: 10, index: 2, type: "PURE_ELECTRIC", sampleCount: 14, startRows: [68, 72], endRows: [77, 81] },
{ row: 11, index: 3, type: "MIXED", sampleCount: 34, startRows: [82, 86], endRows: [111, 115] },
];
for (const spec of intervalSpecs) {
const r = spec.row;
const [ss, se] = spec.startRows;
const [es, ee] = spec.endRows;
summary.getRange(`A${r}:E${r}`).values = [[spec.index, spec.type, spec.sampleCount, `${ss}:${se}`, `${es}:${ee}`]];
summary.getRange(`F${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${ss}:$B$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
summary.getRange(`G${r}`).formulas = [[`=INDEX('样本计算明细'!$B$${es}:$B$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
summary.getRange(`H${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${ss}:$C$${se},MATCH(MEDIAN('样本计算明细'!$T$${ss}:$T$${se}),'样本计算明细'!$T$${ss}:$T$${se},0))`]];
summary.getRange(`I${r}`).formulas = [[`=INDEX('样本计算明细'!$C$${es}:$C$${ee},MATCH(MEDIAN('样本计算明细'!$T$${es}:$T$${ee}),'样本计算明细'!$T$${es}:$T$${ee},0))`]];
const sourceCols = { J: "D", K: "D", L: "E", M: "E", N: "T", O: "T", Q: "AA", R: "AA", W: "AB", X: "AB" };
for (const [targetCol, sourceCol] of Object.entries(sourceCols)) {
const isStart = ["J", "L", "N", "Q", "W"].includes(targetCol);
const rs = isStart ? ss : es;
const re = isStart ? se : ee;
summary.getRange(`${targetCol}${r}`).formulas = [[`=INDEX('样本计算明细'!$${sourceCol}$${rs}:$${sourceCol}$${re},MATCH(MEDIAN('样本计算明细'!$T$${rs}:$T$${re}),'样本计算明细'!$T$${rs}:$T$${re},0))`]];
}
summary.getRange(`P${r}`).formulas = [[spec.type === "MIXED" ? `=MAX(0,N${r}-O${r})` : `=0`]];
summary.getRange(`S${r}`).formulas = [[`=R${r}-Q${r}`]];
summary.getRange(`T${r}`).formulas = [[spec.type === "MIXED" ? `='参数与系数'!$B$5*(Q${r}-R${r})/100` : `=""`]];
summary.getRange(`U${r}`).formulas = [[spec.type === "MIXED" ? `=T${r}/'参数与系数'!$B$6` : `=""`]];
summary.getRange(`V${r}`).formulas = [[spec.type === "MIXED" ? `=P${r}+U${r}` : `=""`]];
summary.getRange(`Y${r}`).formulas = [[`=MAX(0,X${r}-W${r})`]];
summary.getRange(`Z${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,P${r}/Y${r}*100,"")` : `=""`]];
summary.getRange(`AA${r}`).formulas = [[spec.type === "MIXED" ? `=IF(Y${r}>0,V${r}/Y${r}*100,"")` : `=""`]];
summary.getRange(`AB${r}`).values = [[spec.type === "MIXED" ? "是" : "否(仅计纯电里程)"]];
}
styleBody(summary.getRange("A9:AB11"));
summary.getRange("H9:I11").format.numberFormat = "yyyy-mm-dd hh:mm:ss";
summary.getRange("J9:AA11").format.numberFormat = "0.000000";
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "MIXED", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
summary.getRange("B9:B11").conditionalFormats.add("containsText", { text: "PURE_ELECTRIC", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
summary.getRange("A14:AB18").values = [
["人工核验顺序", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["1. 在“原始报文120条”核对每条车辆状态、充电状态、运行模式、压力、温度、SOC和仪表里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["2. 在“样本计算明细”逐项复核Tₖ、9个Z分项、压缩因子Z、分子、分母、质量及参与计算原因。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["3. 在本页核对每个区间首尾5条候选样本的中位质量、对应原始报文ID、物理耗氢、SOC修正和里程。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["4. 停车充电区间不计氢耗;充电结束后重新建立基线。纯电区间只累计纯电里程,不计入混动氢耗分母。", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
];
for (let row = 14; row <= 18; row++) summary.getRange(`A${row}:AB${row}`).merge();
summary.getRange("A14:AB14").format = { fill: colors.cyan, font: { bold: true, color: colors.navy } };
summary.getRange("A15:AB18").format = { fill: colors.pale, font: { color: colors.grayText }, wrapText: true };
summary.freezePanes.freezeRows(7);
summary.freezePanes.freezeColumns(2);
setWidths(summary, [8, 18, 12, 14, 14, 24, 24, 20, 20, 13, 13, 13, 13, 15, 15, 15, 13, 13, 13, 16, 14, 17, 15, 15, 14, 15, 17, 20]);
// 说明首页,关键结果均跨表引用。
styleTitle(guide, guide.getRange("A1:H1"), "氢耗算法120组人工验算底稿");
guide.getRange("A3:H4").merge();
guide.getRange("A3").values = [["用途:供人工使用原始字段逐步复核压力—质量换算、充电剔除、充电后重建基线、纯电/混动分段、SOC能量修正及最终日氢耗。样本为验算构造数据,不代表生产车辆真实报文。"]];
guide.getRange("A3:H4").format = { fill: colors.pale, font: { color: colors.navy }, wrapText: true, verticalAlignment: "center" };
guide.getRange("A6:H6").values = [["核验项目", "数量/结果", "口径", "核验项目", "数量/结果", "口径", "状态", "备注"]];
styleHeader(guide.getRange("A6:H6"));
guide.getRange("A7:H10").values = [
["NIST压力质量样本", null, "12压力×10温度", "原始报文样本", null, "含停车充电和充电后重基线", null, ""],
["NIST通过数", null, "系统值与Excel公式差≤1e-10", "参与计算样本", null, "上/下电窗口及充电剔除后", null, ""],
["物理耗氢", null, "仅有效混动区间", "SOC平衡氢耗", null, "物理耗氢+电池净放电折氢", null, ""],
["混动里程", null, "仅混动区间", "纯电里程", null, "只统计,不进入混动氢耗分母", null, ""],
];
guide.getRange("B7").formulas = [[`=COUNTA('120组压力质量验算'!$A$2:$A$121)`]];
guide.getRange("E7").formulas = [[`=COUNTA('原始报文120条'!$A$2:$A$121)`]];
guide.getRange("G7").formulas = [[`=IF(AND(B7=120,E7=120),"完整","不完整")`]];
guide.getRange("B8").formulas = [[`=COUNTIF('120组压力质量验算'!$U$2:$U$121,"通过")`]];
guide.getRange("E8").formulas = [[`='每日与区间汇总'!$C$4`]];
guide.getRange("G8").formulas = [[`=IF(B8=120,"通过","不通过")`]];
guide.getRange("B9").formulas = [[`='每日与区间汇总'!$J$4`]];
guide.getRange("E9").formulas = [[`='每日与区间汇总'!$N$4`]];
guide.getRange("G9").formulas = [[`='每日与区间汇总'!$H$4`]];
guide.getRange("B10").formulas = [[`='每日与区间汇总'!$O$4`]];
guide.getRange("E10").formulas = [[`=SUMIF('每日与区间汇总'!$B$9:$B$11,"PURE_ELECTRIC",'每日与区间汇总'!$Y$9:$Y$11)`]];
guide.getRange("G10").values = [["可追溯"]];
styleBody(guide.getRange("A7:H10"));
guide.getRange("B9:B10").format.numberFormat = "0.000000";
guide.getRange("E9:E10").format.numberFormat = "0.000000";
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "通过", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "完整", format: { fill: colors.green, font: { color: colors.greenText, bold: true } } });
guide.getRange("G7:G10").conditionalFormats.add("containsText", { text: "可追溯", format: { fill: colors.cyan, font: { color: colors.navy, bold: true } } });
guide.getRange("A13:H13").values = [["工作表", "核验内容", "人工操作", "", "", "", "", ""]];
styleHeader(guide.getRange("A13:H13"));
const guideRows = [
["参数与系数", "所有常量和9组NIST系数", "先确认车型参数,再复核公式引用"],
["120组压力质量验算", "120组压力/温度的完整中间值和最终差异", "逐行查看Z分项→Z→分子/分母→质量→通过结果"],
["原始报文120条", "车辆状态、充电状态、运行模式、压力、温度、SOC、里程及JSON", "核对原始字段,不修改计算列"],
["样本计算明细", "每条报文换算质量、边界过滤、充电识别和区间归属", "按报文ID追踪排除原因及端点候选"],
["每日与区间汇总", "端点中位数、分段耗氢、SOC修正、里程和百公里氢耗", "从最终结果反查起止报文ID"],
];
guide.getRange("A14:C18").values = guideRows;
for (let row = 14; row <= 18; row++) guide.getRange(`C${row}:H${row}`).merge();
styleBody(guide.getRange("A14:H18"));
guide.getRange("A21:H21").merge();
guide.getRange("A21").values = [["关键规则:停车充电 = 车辆状态为熄火(2) 且充电状态为停车充电(1)。充电期间不计算氢耗;充电结束后重新建立SOC、压力、温度和里程基线,仅对运行区间SOC变化进行氢气等效修正。"]];
guide.getRange("A21:H21").format = { fill: colors.orange, font: { bold: true, color: colors.orangeText }, wrapText: true };
guide.getRange("A21:H21").format.rowHeight = 48;
setWidths(guide, [24, 18, 34, 24, 18, 34, 14, 28]);
await fs.mkdir(previewDir, { recursive: true });
const checks = {};
for (const [name, range] of [
["guide", "验算说明!A1:H21"],
["parameters", "参数与系数!A1:F33"],
["grid", "120组压力质量验算!A1:U12"],
["raw", "原始报文120条!A1:N15"],
["detail", "样本计算明细!A1:AL15"],
["summary", "每日与区间汇总!A1:AB18"],
]) {
const [sheetName, a1] = range.split("!");
const inspection = await workbook.inspect({ kind: "table", sheetId: sheetName, range: a1, include: "values,formulas", tableMaxRows: 20, tableMaxCols: 40, maxChars: 12000 });
checks[name] = inspection.ndjson;
}
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",
maxChars: 10000,
});
for (const sheetName of ["验算说明", "参数与系数", "120组压力质量验算", "原始报文120条", "样本计算明细", "每日与区间汇总"]) {
const preview = await workbook.render({ sheetName, autoCrop: "all", scale: 0.8, format: "png" });
await fs.writeFile(`${previewDir}/${sheetName}.png`, new Uint8Array(await preview.arrayBuffer()));
}
const xlsx = await SpreadsheetFile.exportXlsx(workbook);
await xlsx.save(outputPath);
await fs.writeFile(`${outputDir}/verification.json`, JSON.stringify({ outputPath, checks, formulaErrors: formulaErrors.ndjson }, null, 2));
console.log(JSON.stringify({ outputPath, previewDir, formulaErrors: formulaErrors.ndjson }));
@@ -0,0 +1,446 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const evidencePath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083";
const outputPath = path.join(outputDir, "15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx");
const previewDir = path.join(outputDir, "preview");
const data = JSON.parse(await fs.readFile(evidencePath, "utf8"));
const workbook = Workbook.create();
const summary = workbook.worksheets.add("申诉总览");
const notes = workbook.worksheets.add("口径与证据说明");
const vehicleSheets = new Map();
for (const vehicle of data.vehicles) {
const sheetName = `${String(vehicle.seq).padStart(2, "0")}-${vehicle.plate}`;
vehicleSheets.set(vehicle.vin, { sheetName, sheet: workbook.worksheets.add(sheetName) });
}
const palette = {
navy: "#1F4E78",
blue: "#D9EAF7",
blue2: "#EAF3F8",
yellow: "#FFF2CC",
amber: "#FCE4D6",
mileageHeader: "#C65911",
mileageLight: "#FFF2CC",
mileageStrong: "#FFD966",
green: "#E2F0D9",
gray: "#E7E6E6",
dark: "#1F2937",
muted: "#5B6573",
border: "#B7C9D6",
white: "#FFFFFF",
};
const thinBorders = { preset: "all", style: "thin", color: palette.border };
const titleFormat = {
fill: palette.navy,
font: { bold: true, color: palette.white, fontSize: 16, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
};
const sectionFormat = {
fill: palette.blue,
font: { bold: true, color: palette.dark, fontSize: 11, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
borders: thinBorders,
};
const headerFormat = {
fill: palette.navy,
font: { bold: true, color: palette.white, fontSize: 9, typeface: "Microsoft YaHei" },
horizontalAlignment: "center",
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
const labelFormat = {
fill: palette.blue2,
font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
horizontalAlignment: "left",
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
const valueFormat = {
font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" },
verticalAlignment: "center",
wrapText: true,
borders: thinBorders,
};
function asDate(day) {
return new Date(`${day}T00:00:00Z`);
}
function addDays(day, count) {
const date = asDate(day);
date.setUTCDate(date.getUTCDate() + count);
return date.toISOString().slice(0, 10);
}
function calendarDays(start, end) {
const days = [];
for (let day = start; day <= end; day = addDays(day, 1)) days.push(day);
return days;
}
function absDelta(a, b) {
if (a == null || b == null || Number.isNaN(Number(a)) || Number.isNaN(Number(b))) return null;
return Math.abs(Number(a) - Number(b));
}
function rawRange(vehicle) {
const rows = vehicle.rawEvidence.filter((row) => row.status === "OK");
if (rows.length === 0) return "无";
return `${rows[0].day}${rows.at(-1).day}`;
}
function evidenceLevel(metric, raw) {
if (raw?.status === "OK") return "A-原始32960报文";
if (metric?.source_code === "legacy_lingniu_prod") return "C-历史日统计迁移(无RAW";
if (metric) return "B-中台32960日统计(无RAW";
return "D-无中台日记录";
}
function rowNote(metric, raw) {
const notes = [];
if (metric?.source_code === "legacy_lingniu_prod") notes.push("历史生产库 ln_vehicle_day_mileage 迁移记录,不是原始帧");
if (raw?.status === "OK" && !metric) notes.push("有原始报文,但中台日统计无记录");
if (raw?.status === "OK" && metric && absDelta(metric.latest_total_mileage_km, raw.parsedTotalMileageKm) > 0.1) {
notes.push("日统计日末值与本日最后可得RAW读数不一致,详见数据源/时间;可能涉及人工源选举或跨日延迟");
}
if (raw?.status !== "OK" && metric && metric.source_code !== "legacy_lingniu_prod") notes.push("有中台日统计,但当日未检出含仪表总里程的RAW帧");
if (!metric && raw?.status !== "OK") notes.push("当日无中台日记录,且现行RAW历史不可用或未检出");
return notes.join("");
}
function setStandardSheet(sheet) {
sheet.showGridLines = false;
sheet.freezePanes.freezeRows(13);
sheet.freezePanes.freezeColumns(1);
}
for (const vehicle of data.vehicles) {
const { sheetName, sheet } = vehicleSheets.get(vehicle.vin);
setStandardSheet(sheet);
sheet.getRange("A1:P1").merge();
sheet.getRange("A1").values = [[`${vehicle.plate} 里程核验申诉明细`]];
sheet.getRange("A1:P1").format = titleFormat;
sheet.getRange("A1:P1").format.rowHeight = 30;
sheet.getRange("A2:P2").merge();
sheet.getRange("A2").values = [["核验口径:GB/T 32960 车辆仪表累计总里程;原始报文列保存完整HEX,点击单元格可在公式栏复制。相邻自然日均有RAW读数时才计算RAW日差。"]];
sheet.getRange("A2:P2").format = { fill: palette.yellow, font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
sheet.getRange("A2:P2").format.rowHeight = 30;
sheet.getRange("A4:P6").format = valueFormat;
sheet.getRange("A4").values = [["车牌"]];
sheet.getRange("B4:C4").merge(); sheet.getRange("B4").values = [[vehicle.plate]];
sheet.getRange("D4").values = [["VIN"]];
sheet.getRange("E4:H4").merge(); sheet.getRange("E4").values = [[vehicle.vin]];
sheet.getRange("I4").values = [["车型"]];
sheet.getRange("J4:K4").merge(); sheet.getRange("J4").values = [[vehicle.model]];
sheet.getRange("L4").values = [["协议"]];
sheet.getRange("M4:P4").merge(); sheet.getRange("M4").values = [["GB32960(仪表累计总里程)"]];
sheet.getRange("A5").values = [["核验区间"]];
sheet.getRange("B5:C5").merge(); sheet.getRange("B5").values = [["2025-11-28 至 2026-07-31"]];
sheet.getRange("D5").values = [["原TBOX合计(km)"]];
sheet.getRange("E5:F5").merge(); sheet.getRange("E5").values = [[vehicle.sourceTboxKm]];
sheet.getRange("G5").values = [["第三方平台合计(km)"]];
sheet.getRange("H5:I5").merge(); sheet.getRange("H5").values = [[vehicle.platformKm]];
sheet.getRange("J5").values = [["原表损耗(km)"]];
sheet.getRange("K5:L5").merge(); sheet.getRange("K5").values = [[vehicle.lossKm]];
sheet.getRange("M5").values = [["原申诉里程(km)"]];
sheet.getRange("N5:P5").merge(); sheet.getRange("N5").values = [[vehicle.appealKm]];
sheet.getRange("E5:F5").format.numberFormat = "#,##0.0";
sheet.getRange("H5:I5").format.numberFormat = "#,##0.0";
sheet.getRange("K5:L5").format.numberFormat = "#,##0.0";
sheet.getRange("N5:P5").format.numberFormat = "#,##0.0";
sheet.getRange("K5:L5").format.fill = palette.yellow;
sheet.getRange("A6").values = [["异常标记"]];
sheet.getRange("B6:D6").merge(); sheet.getRange("B6").values = [[vehicle.anomaly]];
sheet.getRange("E6").values = [["原排查结论"]];
sheet.getRange("F6:P6").merge(); sheet.getRange("F6").values = [[vehicle.note]];
for (const address of ["A4", "A5", "A6", "D4", "D5", "E6", "G5", "I4", "J5", "L4", "M5"]) sheet.getRange(address).format = labelFormat;
sheet.getRange("A8:P8").format = valueFormat;
sheet.getRange("A8").values = [["原车辆统计合计(km)"]];
sheet.getRange("B8:C8").merge(); sheet.getRange("B8").values = [[vehicle.sourceTboxKm]];
sheet.getRange("D8").values = [["车辆数据中台统计合计(km)"]];
sheet.getRange("E8:F8").merge(); sheet.getRange("E8").formulas = [["=SUM(B14:B259)"]];
sheet.getRange("G8").values = [["中台日统计有值天数"]];
sheet.getRange("H8:I8").merge(); sheet.getRange("H8").formulas = [["=COUNT(B14:B259)"]];
sheet.getRange("J8").values = [["RAW仪表证据天数"]];
sheet.getRange("K8:L8").merge(); sheet.getRange("K8").formulas = [["=COUNT(F14:F259)"]];
sheet.getRange("M8").values = [["RAW证据日期范围"]];
sheet.getRange("N8:P8").merge(); sheet.getRange("N8").values = [[rawRange(vehicle)]];
for (const address of ["A8", "D8", "G8", "J8", "M8"]) sheet.getRange(address).format = labelFormat;
sheet.getRange("B8:C8").format.numberFormat = "#,##0.0";
sheet.getRange("E8:F8").format.numberFormat = "#,##0.0";
sheet.getRange("B8:C8").format.fill = palette.blue2;
sheet.getRange("E8:F8").format.fill = palette.mileageStrong;
sheet.getRange("E8:F8").format.font = { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" };
sheet.getRange("A8:P8").format.rowHeight = 38;
sheet.getRange("A9").values = [["证据限制"]];
sheet.getRange("B9:P9").merge();
sheet.getRange("B9").values = [["现行TDengine RAW历史自2026-07-01起可查询;此前仅有历史生产库日统计迁移记录。材料已按A/B/C/D分级,未将统计记录伪称为原始报文。"]];
sheet.getRange("A9").format = labelFormat;
sheet.getRange("B9:P9").format = { ...valueFormat, fill: palette.amber };
sheet.getRange("A9:P9").format.rowHeight = 30;
sheet.getRange("A11:P11").merge();
sheet.getRange("A11").values = [["每日核验明细(完整日历)"]];
sheet.getRange("A11:P11").format = sectionFormat;
sheet.getRange("A12:D12").merge();
sheet.getRange("A12").values = [["车辆数据中台区间里程总计"]];
sheet.getRange("E12:F12").merge();
sheet.getRange("E12").formulas = [["=SUM(B14:B259)"]];
sheet.getRange("G12:P12").merge();
sheet.getRange("G12").values = [["计算范围:B14:B259(下方“中台当日里程(km)”列)"]];
sheet.getRange("A12:D12").format = {
...headerFormat,
fill: palette.mileageHeader,
horizontalAlignment: "left",
};
sheet.getRange("E12:F12").format = {
...valueFormat,
fill: palette.mileageStrong,
font: { bold: true, color: "#7F6000", fontSize: 11, typeface: "Microsoft YaHei" },
numberFormat: "#,##0.0",
horizontalAlignment: "right",
};
sheet.getRange("G12:P12").format = {
...valueFormat,
fill: palette.mileageLight,
font: { color: palette.textMuted, fontSize: 9, typeface: "Microsoft YaHei" },
};
sheet.getRange("A12:P12").format.rowHeight = 30;
const headers = ["日期", "中台当日里程(km)", "中台日末仪表总里程(km)", "日统计数据源", "日统计来源端点", "RAW解析仪表总里程(km)", "RAW相邻日差(km)", "日统计-RAW差(km)", "证据等级", "报文事件时间", "中台接收时间", "RAW来源端点", "Event ID", "Frame ID", "原始报文大小(bytes)", "原始GB32960报文HEX(完整)"];
sheet.getRange("A13:P13").values = [headers];
sheet.getRange("A13:P13").format = headerFormat;
sheet.getRange("A13:P13").format.rowHeight = 42;
const metricByDay = new Map(vehicle.dailyMetrics.map((row) => [row.stat_date, row]));
const rawByDay = new Map(vehicle.rawEvidence.map((row) => [row.day, row]));
const days = calendarDays(data.period.start, data.period.end);
const rows = days.map((day) => {
const metric = metricByDay.get(day);
const raw = rawByDay.get(day);
return [
asDate(day),
metric?.daily_mileage_km ?? null,
metric?.latest_total_mileage_km ?? null,
metric ? `${metric.platform_name || ""} / ${metric.source_code || ""}` : "",
metric?.latest_source_endpoint ?? "",
raw?.status === "OK" ? raw.parsedTotalMileageKm : null,
null,
null,
evidenceLevel(metric, raw),
raw?.status === "OK" ? raw.eventTime : "",
raw?.status === "OK" ? raw.receivedAt : "",
raw?.status === "OK" ? raw.sourceEndpoint : "",
raw?.status === "OK" ? raw.eventId : "",
raw?.status === "OK" ? raw.frameId : "",
raw?.status === "OK" ? raw.rawSizeBytes : null,
raw?.status === "OK" ? raw.rawHex : "",
];
});
sheet.getRange("A14:P259").values = rows;
sheet.getRange("A14:A259").format.numberFormat = "yyyy-mm-dd";
sheet.getRange("B14:C259").format.numberFormat = "#,##0.0";
sheet.getRange("F14:H259").format.numberFormat = "#,##0.0";
sheet.getRange("O14:O259").format.numberFormat = "#,##0";
sheet.getRange("A14:P259").format = {
font: { color: palette.dark, fontSize: 8, typeface: "Microsoft YaHei" },
verticalAlignment: "center",
borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } },
};
sheet.getRange("A14:A259").format.horizontalAlignment = "center";
sheet.getRange("B14:C259").format.horizontalAlignment = "right";
sheet.getRange("F14:H259").format.horizontalAlignment = "right";
sheet.getRange("I14:O259").format.wrapText = true;
sheet.getRange("P14:P259").format = { font: { color: palette.muted, fontSize: 7, typeface: "Consolas" }, wrapText: true, verticalAlignment: "top" };
sheet.getRange("A14:P259").format.rowHeight = 34;
sheet.getRange("G14").values = [[null]];
for (let row = 15; row <= 259; row += 1) {
sheet.getRange(`G${row}`).formulas = [[`=IF(OR(F${row}="",F${row - 1}=""),"",F${row}-F${row - 1})`]];
}
for (let row = 14; row <= 259; row += 1) {
sheet.getRange(`H${row}`).formulas = [[`=IF(OR(C${row}="",F${row}=""),"",C${row}-F${row})`]];
}
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "A-", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "C-", format: { fill: palette.yellow, font: { color: "#7F6000" } } });
sheet.getRange("I14:I259").conditionalFormats.add("containsText", { text: "D-", format: { fill: palette.gray, font: { color: palette.muted } } });
sheet.getRange("H14:H259").conditionalFormats.addCustom("=ABS($H14)>0.1", { fill: palette.amber, font: { color: "#9C0006", bold: true } });
const table = sheet.tables.add("A13:P259", true, `Vehicle${String(vehicle.seq).padStart(2, "0")}Evidence`);
table.style = "TableStyleMedium2";
table.showBandedColumns = false;
table.showFilterButton = true;
sheet.getRange("B13").format = { ...headerFormat, fill: palette.mileageHeader };
sheet.getRange("B14:B259").format.fill = palette.mileageLight;
sheet.getRange("B14:B259").conditionalFormats.add("cellIs", {
operator: "greaterThan",
formula: 0,
format: { fill: palette.mileageStrong, font: { bold: true, color: "#7F6000" } },
});
const widths = [12, 17, 20, 24, 26, 20, 17, 18, 25, 20, 20, 22, 34, 34, 14, 72];
for (let col = 0; col < widths.length; col += 1) sheet.getRangeByIndexes(0, col, 259, 1).format.columnWidth = widths[col];
}
summary.showGridLines = false;
summary.freezePanes.freezeRows(4);
summary.getRange("A1:P1").merge();
summary.getRange("A1").values = [["15辆车里程核验申诉总览"]];
summary.getRange("A1:P1").format = titleFormat;
summary.getRange("A1:P1").format.rowHeight = 32;
summary.getRange("A2:P2").merge();
summary.getRange("A2").values = [["结论:车辆数据中台GB32960日里程复核合计与原异常表TBOX区间里程逐车一致;第三方平台合计显著偏低。RAW原始帧按现有可查历史提供,早期记录已明确标注证据限制。"]];
summary.getRange("A2:P2").format = { fill: palette.green, font: { bold: true, color: "#375623", fontSize: 10, typeface: "Microsoft YaHei" }, wrapText: true, verticalAlignment: "center", borders: thinBorders };
summary.getRange("A2:P2").format.rowHeight = 34;
summary.getRange("A3:B3").merge(); summary.getRange("A3").values = [["全部15辆车·原车辆统计(km)"]];
summary.getRange("C3").formulas = [["=F20"]];
summary.getRange("D3:E3").merge(); summary.getRange("D3").values = [["车辆数据中台统计(km)"]];
summary.getRange("F3").formulas = [["=G20"]];
summary.getRange("G3:H3").merge(); summary.getRange("G3").values = [["第三方平台统计(km)"]];
summary.getRange("I3").formulas = [["=H20"]];
summary.getRange("J3:K3").merge(); summary.getRange("J3").values = [["平台漏计差异(km)"]];
summary.getRange("L3").formulas = [["=I20"]];
summary.getRange("M3:N3").merge(); summary.getRange("M3").values = [["RAW仪表证据(天)"]];
summary.getRange("O3:P3").merge(); summary.getRange("O3").formulas = [["=M20"]];
for (const address of ["A3:B3", "D3:E3", "G3:H3", "J3:K3", "M3:N3"]) summary.getRange(address).format = labelFormat;
for (const address of ["C3", "F3", "I3", "L3", "O3:P3"]) summary.getRange(address).format = { ...valueFormat, fill: palette.mileageLight, font: { bold: true, color: "#7F6000", fontSize: 10, typeface: "Microsoft YaHei" }, horizontalAlignment: "right" };
summary.getRange("C3:L3").format.numberFormat = "#,##0.0";
summary.getRange("O3:P3").format.numberFormat = "#,##0";
summary.getRange("A3:P3").format.rowHeight = 34;
const summaryHeaders = ["序号", "车型", "车牌", "VIN", "核验区间", "原TBOX合计(km)", "中台复核合计(km)", "第三方平台合计(km)", "损耗(km)", "损耗率", "申诉里程(km)", "日统计天数", "RAW证据天数", "RAW证据范围", "核验结论", "原排查结论"];
summary.getRange("A4:P4").values = [summaryHeaders];
summary.getRange("A4:P4").format = headerFormat;
summary.getRange("A4:P4").format.rowHeight = 40;
const summaryRows = data.vehicles.map((vehicle) => [
vehicle.seq, vehicle.model, vehicle.plate, vehicle.vin, "2025-11-28 至 2026-07-31", vehicle.sourceTboxKm, null, vehicle.platformKm, null, null, vehicle.appealKm, null, null, rawRange(vehicle), null, vehicle.note,
]);
summary.getRange("A5:P19").values = summaryRows;
for (let index = 0; index < data.vehicles.length; index += 1) {
const row = 5 + index;
const { sheetName } = vehicleSheets.get(data.vehicles[index].vin);
summary.getRange(`G${row}`).formulas = [[`='${sheetName}'!E8`]];
summary.getRange(`I${row}`).formulas = [[`=F${row}-H${row}`]];
summary.getRange(`J${row}`).formulas = [[`=IF(F${row}=0,"",I${row}/F${row})`]];
summary.getRange(`L${row}`).formulas = [[`='${sheetName}'!H8`]];
summary.getRange(`M${row}`).formulas = [[`='${sheetName}'!K8`]];
summary.getRange(`O${row}`).formulas = [[`=IF(ABS(G${row}-F${row})<0.1,"中台复核合计与原TBOX一致","需复核中台合计")`]];
}
summary.getRange("A20:E20").merge(); summary.getRange("A20").values = [["合计"]];
summary.getRange("F20").formulas = [["=SUM(F5:F19)"]];
summary.getRange("G20").formulas = [["=SUM(G5:G19)"]];
summary.getRange("H20").formulas = [["=SUM(H5:H19)"]];
summary.getRange("I20").formulas = [["=SUM(I5:I19)"]];
summary.getRange("J20").formulas = [["=IF(F20=0,\"\",I20/F20)"]];
summary.getRange("K20").formulas = [["=SUM(K5:K19)"]];
summary.getRange("L20").formulas = [["=SUM(L5:L19)"]];
summary.getRange("M20").formulas = [["=SUM(M5:M19)"]];
summary.getRange("N20:P20").merge(); summary.getRange("N20").values = [["15车合计;RAW证据为现行历史可查询范围"]];
summary.getRange("A20:P20").format = { fill: palette.blue, font: { bold: true, color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, borders: thinBorders, verticalAlignment: "center", wrapText: true };
summary.getRange("A5:P19").format = { font: { color: palette.dark, fontSize: 9, typeface: "Microsoft YaHei" }, verticalAlignment: "center", wrapText: true, borders: { insideHorizontal: { style: "thin", color: "#D9E2E8" }, bottom: { style: "thin", color: palette.border } } };
summary.getRange("F5:I20").format.numberFormat = "#,##0.0";
summary.getRange("J5:J20").format.numberFormat = "0.00%";
summary.getRange("K5:K20").format.numberFormat = "#,##0.0";
summary.getRange("L5:M20").format.numberFormat = "#,##0";
summary.getRange("I5:I19").format.fill = palette.yellow;
summary.getRange("O5:O19").conditionalFormats.add("containsText", { text: "一致", format: { fill: palette.green, font: { color: "#375623", bold: true } } });
const summaryTable = summary.tables.add("A4:P19", true, "AppealSummary");
summaryTable.style = "TableStyleMedium2";
summaryTable.showFilterButton = true;
const summaryWidths = [8, 9, 14, 22, 22, 16, 18, 18, 15, 12, 16, 13, 14, 24, 26, 54];
for (let col = 0; col < summaryWidths.length; col += 1) summary.getRangeByIndexes(0, col, 20, 1).format.columnWidth = summaryWidths[col];
summary.getRange("A5:P19").format.rowHeight = 42;
notes.showGridLines = false;
notes.getRange("A1:H1").merge();
notes.getRange("A1").values = [["核验口径与证据说明"]];
notes.getRange("A1:H1").format = titleFormat;
notes.getRange("A1:H1").format.rowHeight = 32;
notes.getRange("A3:H3").merge(); notes.getRange("A3").values = [["1. 核验口径"]]; notes.getRange("A3:H3").format = sectionFormat;
notes.getRange("A4:B8").values = [
["协议", "GB/T 32960"],
["仪表总里程字段", "gb32960.vehicle.total_mileage_km"],
["仪表总里程含义", "车辆仪表盘累计总里程,单位 km;不同协议累计值不可拼接。"],
["中台当日里程", "生产 vehicle_daily_mileage 的 GB32960 日统计值;本材料逐车求和后与原异常表TBOX合计核对。"],
["RAW相邻日差", "仅相邻两个自然日都存在RAW仪表读数时计算;缺任一日则留空,不把跨日缺口跳变塞进单日。"],
];
notes.getRange("A4:A8").format = labelFormat;
notes.getRange("B4:H8").merge(true);
notes.getRange("B4:H8").format = valueFormat;
notes.getRange("A10:H10").merge(); notes.getRange("A10").values = [["2. 证据分级"]]; notes.getRange("A10:H10").format = sectionFormat;
notes.getRange("A11:B14").values = [
["A-原始32960报文", "中台TDengine保存的完整GB32960十六进制报文,且解析结果明确包含仪表总里程字段。"],
["B-中台32960日统计", "中台日统计存在,但当日未检出含仪表总里程的RAW帧。"],
["C-历史日统计迁移", "来自历史生产库 lingniu_prod.ln_vehicle_day_mileage 的迁移记录,不是原始帧。"],
["D-无中台日记录", "当日中台日统计无记录,且现行RAW历史不可用或未检出。"],
];
notes.getRange("A11:A14").format = labelFormat;
notes.getRange("B11:H14").merge(true);
notes.getRange("B11:H14").format = valueFormat;
notes.getRange("A11:H11").format.fill = palette.green;
notes.getRange("A13:H13").format.fill = palette.yellow;
notes.getRange("A14:H14").format.fill = palette.gray;
notes.getRange("A16:H16").merge(); notes.getRange("A16").values = [["3. 数据来源与限制"]]; notes.getRange("A16:H16").format = sectionFormat;
notes.getRange("A17:B21").values = [
["原异常表", "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx"],
["中台日统计API", "http://115.29.187.205:20200/api/stats/daily-metrics"],
["中台RAW历史API", "http://115.29.187.205:20200/api/history/raw-frames"],
["RAW可查范围", "本次提取范围2026-07-01至2026-07-3115车共取得334个含仪表总里程字段的完整原始帧。"],
["重要限制", "2026-07-01之前现行TDengine没有可查询RAW历史;只能提供历史生产库日统计迁移记录。对外申诉时应如实说明,并向原TSP/车厂补调更早期原始报文归档。"],
];
notes.getRange("A17:A21").format = labelFormat;
notes.getRange("B17:H21").merge(true);
notes.getRange("B17:H21").format = valueFormat;
notes.getRange("A21:H21").format.fill = palette.amber;
notes.getRange("A23:H23").merge(); notes.getRange("A23").values = [["4. 使用建议"]]; notes.getRange("A23:H23").format = sectionFormat;
notes.getRange("A24:H27").values = [
["① 先提交“申诉总览”,证明15车中台GB32960日里程合计与原TBOX合计一致。", null, null, null, null, null, null, null],
["② 每车附对应Sheet,重点引用A类RAW证据、事件时间、接收时间和完整HEX。", null, null, null, null, null, null, null],
["③ 对第三方缺失日,用完整日历中的D级空缺、前后仪表累计变化和原排查结论说明漏传。", null, null, null, null, null, null, null],
["④ 申诉若要求全区间逐日原始报文,需由原TSP/车厂补调2026-07-01之前归档;本材料未虚构缺失证据。", null, null, null, null, null, null, null],
];
for (let row = 24; row <= 27; row += 1) notes.getRange(`A${row}:H${row}`).merge();
notes.getRange("A24:H27").format = { ...valueFormat, fill: palette.blue2 };
notes.getRange("A4:H27").format.rowHeight = 30;
notes.getRange("A1:A27").format.columnWidth = 22;
for (let col = 1; col < 8; col += 1) notes.getRangeByIndexes(0, col, 27, 1).format.columnWidth = 18;
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(previewDir, { recursive: true });
const inspection = {};
inspection.summary = (await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 24, tableMaxCols: 16, maxChars: 22000 })).ndjson;
inspection.formulaErrors = (await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" })).ndjson;
await fs.writeFile(path.join(outputDir, "verification.ndjson"), `${inspection.summary}\n${inspection.formulaErrors}\n`, "utf8");
const previewSheets = ["申诉总览", "口径与证据说明", ...data.vehicles.map((vehicle) => vehicleSheets.get(vehicle.vin).sheetName)];
for (let index = 0; index < previewSheets.length; index += 1) {
const sheetName = previewSheets[index];
const range = sheetName === "申诉总览" ? "A1:P20" : sheetName === "口径与证据说明" ? "A1:H27" : "A1:P24";
const preview = await workbook.render({ sheetName, range, scale: 1, format: "png" });
const safeName = sheetName.replaceAll(/[\\/:*?\[\]]/g, "_");
await fs.writeFile(path.join(previewDir, `${String(index + 1).padStart(2, "0")}-${safeName}.png`), new Uint8Array(await preview.arrayBuffer()));
}
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputPath);
console.log(JSON.stringify({ outputPath, sheets: previewSheets.length, previewDir }, null, 2));
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
const baseUrl = "http://115.29.187.205:20200";
const outputPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/evidence.json";
const vehicles = [
{ seq: 1, model: "18T", plate: "粤A05995F", vin: "LNXNEGRR7SR321370", sourceTboxKm: 26180.6, platformKm: 4208.9, lossKm: 21971.7, lossRatePct: 83.92, anomaly: "里程损失,tbox异常", appealKm: 41497.1, note: "区间内仪表盘开始里程是576.6,结束里程是41497.1,差值40920.5,小于申诉里程;2026-05-11到2026-07-28间的数据没上传,仪表盘里程差了36306.6,2025-12-25到2026-01-01间还存在里程跳变导致里程缺失" },
{ seq: 2, model: "18T", plate: "粤A05839F", vin: "LNXNEGRR8SR321376", sourceTboxKm: 41030.3, platformKm: 19763.6, lossKm: 21266.7, lossRatePct: 51.83, anomaly: "里程损失", appealKm: 41030.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,特别是7月份仪表里程从7月1号的16341.2到7月31号的41848.9,相差25507.7,计算的总里程只有8470" },
{ seq: 3, model: "18T", plate: "粤A09369F", vin: "LNXNEGRR6SR321473", sourceTboxKm: 40381.5, platformKm: 24994.0, lossKm: 15387.5, lossRatePct: 38.11, anomaly: "里程损失", appealKm: 40381.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 4, model: "18T", plate: "粤A06658F", vin: "LNXNEGRR5SR321397", sourceTboxKm: 52386.5, platformKm: 33064.5, lossKm: 19322.0, lossRatePct: 36.88, anomaly: "里程损失", appealKm: 52386.5, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 5, model: "18T", plate: "粤A05700F", vin: "LNXNEGRR8SR321393", sourceTboxKm: 60054.3, platformKm: 41352.9, lossKm: 18701.4, lossRatePct: 31.14, anomaly: "里程损失", appealKm: 60054.3, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 6, model: "18T", plate: "粤A05906F", vin: "LNXNEGRR2SR319462", sourceTboxKm: 37216.8, platformKm: 26711.4, lossKm: 10505.4, lossRatePct: 28.23, anomaly: "里程损失", appealKm: 37216.8, note: "最新数据只到2026-06-30,仪表盘最后里程为28638.5,没有7月份的数据;也存在很多数据缺失导致的里程缺失,例如2026-03-26到2026-04-01,2026-04-21到2026-05-01间没上传数据" },
{ seq: 7, model: "4.5T", plate: "粤AGP9346", vin: "LB9A32A28R0LS1722", sourceTboxKm: 32337.2, platformKm: 28685.4, lossKm: 3651.8, lossRatePct: 11.29, anomaly: "里程损失", appealKm: 32337.2, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 8, model: "4.5T", plate: "粤AGP2017", vin: "LB9A32A28R0LS1445", sourceTboxKm: 48164.9, platformKm: 44357.1, lossKm: 3807.8, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 48164.9, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-31的47337.5,小于申诉里程" },
{ seq: 9, model: "4.5T", plate: "粤AGP5165", vin: "LB9A32A25R0LS1385", sourceTboxKm: 32906.4, platformKm: 30302.3, lossKm: 2604.1, lossRatePct: 7.91, anomaly: "里程损失", appealKm: 32906.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,而且区间内最后的仪表里程是2026-07-30的31994.6,2026-07-31没有数据上传" },
{ seq: 10, model: "4.5T", plate: "粤AGP4355", vin: "LB9A32A26R0LS1606", sourceTboxKm: 55151.0, platformKm: 51525.8, lossKm: 3625.2, lossRatePct: 6.57, anomaly: "里程损失", appealKm: 55151.0, note: "区间内仪表盘开始里程是1898.6,结束里程是55229.6,差值53331,小于申诉里程,也存在很多数据缺失导致的里程缺失" },
{ seq: 11, model: "4.5T", plate: "粤AGP5646", vin: "LB9A32A22R0LS1702", sourceTboxKm: 39935.0, platformKm: 37319.8, lossKm: 2615.2, lossRatePct: 6.55, anomaly: "里程损失", appealKm: 39935.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 12, model: "4.5T", plate: "粤AGP3692", vin: "LB9A32A27R0LS1727", sourceTboxKm: 64195.0, platformKm: 60242.6, lossKm: 3952.4, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 64195.0, note: "存在很多数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 13, model: "4.5T", plate: "粤AGP9782", vin: "LB9A32A28R0LS1705", sourceTboxKm: 50216.4, platformKm: 47123.8, lossKm: 3092.6, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 50216.4, note: "存在很多数据缺失导致的里程跳变,统计里程缺失,如:2026-05-21数据缺失导致里程少了1300多" },
{ seq: 14, model: "4.5T", plate: "粤AGP3027", vin: "LB9A32A22R0LS1599", sourceTboxKm: 31180.9, platformKm: 29260.1, lossKm: 1920.8, lossRatePct: 6.16, anomaly: "里程损失", appealKm: 31180.9, note: "区间内仪表盘开始里程是933.5,结束里程是31245.3,差值30311.8,小于申诉里程,也存在一些数据缺失导致的里程跳变,统计里程缺失" },
{ seq: 15, model: "4.5T", plate: "粤AGP9751", vin: "LB9A32A28R0LS1574", sourceTboxKm: 17944.6, platformKm: 16861.0, lossKm: 1083.6, lossRatePct: 6.04, anomaly: "里程损失", appealKm: 17944.6, note: "区间内仪表盘开始里程是205.2,结束里程是17796.8,差值17591.6,小于申诉里程,也存在一些数据缺失导致的里程缺失" },
];
function dateString(date) {
return date.toISOString().slice(0, 10);
}
function addDays(day, count) {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + count);
return dateString(date);
}
function dateRange(start, end) {
const out = [];
for (let day = start; day <= end; day = addDays(day, 1)) out.push(day);
return out;
}
async function fetchJson(url, attempts = 4) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
return await response.json();
} catch (error) {
lastError = error;
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
}
}
throw lastError;
}
async function mapConcurrent(items, concurrency, worker) {
const results = new Array(items.length);
let cursor = 0;
async function run() {
while (true) {
const index = cursor;
cursor += 1;
if (index >= items.length) return;
results[index] = await worker(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run));
return results;
}
async function fetchDaily(vehicle) {
const params = new URLSearchParams({
vin: vehicle.vin,
protocol: "GB32960",
dateFrom: "2025-11-28",
dateTo: "2026-07-31",
limit: "500",
includeTotal: "true",
});
const data = await fetchJson(`${baseUrl}/api/stats/daily-metrics?${params}`);
return data.items ?? [];
}
async function fetchRawEvidence({ vehicle, day }) {
const params = new URLSearchParams({
protocol: "GB32960",
vin: vehicle.vin,
dateFrom: `${day} 00:00:00`,
dateTo: `${addDays(day, 1)} 00:00:00`,
orderBy: "eventTime",
limit: "20",
includeFields: "true",
includePayload: "true",
fields: "gb32960.vehicle.total_mileage_km",
});
let data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
let evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
if (!evidence && (data.items ?? []).length === 20) {
params.set("limit", "100");
data = await fetchJson(`${baseUrl}/api/history/raw-frames?${params}`);
evidence = (data.items ?? []).find((item) => item?.parsed_fields?.["gb32960.vehicle.total_mileage_km"] != null && String(item.event_time ?? "").startsWith(day));
}
if (!evidence) return { day, status: "NO_RAW_ODOMETER_FRAME" };
return {
day,
status: "OK",
ts: evidence.ts ?? "",
eventTime: evidence.event_time ?? "",
receivedAt: evidence.received_at ?? "",
frameId: evidence.frame_id ?? "",
eventId: evidence.event_id ?? "",
messageIdHex: evidence.message_id_hex ?? "",
rawSizeBytes: evidence.raw_size_bytes ?? null,
rawHex: evidence.raw_hex ?? "",
parseStatus: evidence.parse_status ?? "",
parseError: evidence.parse_error ?? "",
sourceEndpoint: evidence.source_endpoint ?? "",
parsedTotalMileageKm: Number(evidence.parsed_fields["gb32960.vehicle.total_mileage_km"]),
};
}
const dailyLists = await mapConcurrent(vehicles, 6, fetchDaily);
const rawDays = dateRange("2026-07-01", "2026-07-31");
const rawTasks = vehicles.flatMap((vehicle) => rawDays.map((day) => ({ vehicle, day })));
let completed = 0;
const rawResults = await mapConcurrent(rawTasks, 10, async (task) => {
const result = await fetchRawEvidence(task);
completed += 1;
if (completed % 50 === 0 || completed === rawTasks.length) console.log(`raw evidence ${completed}/${rawTasks.length}`);
return { vin: task.vehicle.vin, ...result };
});
const byVinRaw = new Map();
for (const item of rawResults) {
if (!byVinRaw.has(item.vin)) byVinRaw.set(item.vin, []);
byVinRaw.get(item.vin).push(item);
}
const result = {
generatedAt: new Date().toISOString(),
baseUrl,
period: { start: "2025-11-28", end: "2026-07-31" },
protocol: "GB32960",
parsedMileageField: "gb32960.vehicle.total_mileage_km",
rawEvidenceAvailability: { start: "2026-07-01", end: "2026-07-31", note: "现行TDengine RAW历史自2026-07-01起;此前中台仅保留历史生产库日统计迁移记录,不能伪称原始帧。" },
vehicles: vehicles.map((vehicle, index) => ({
...vehicle,
dailyMetrics: dailyLists[index],
rawEvidence: (byVinRaw.get(vehicle.vin) ?? []).sort((a, b) => a.day.localeCompare(b.day)),
})),
};
await fs.writeFile(outputPath, JSON.stringify(result, null, 2), "utf8");
console.log(JSON.stringify({
outputPath,
vehicles: result.vehicles.length,
dailyRows: result.vehicles.reduce((sum, item) => sum + item.dailyMetrics.length, 0),
rawEvidenceRows: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status === "OK").length, 0),
rawMissingDays: result.vehicles.reduce((sum, item) => sum + item.rawEvidence.filter((row) => row.status !== "OK").length, 0),
}, null, 2));
@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const inputPath = "/Users/lingniu/Documents/里程异常统计表_20251128-20260731-回复.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/tmp/mileage-appeal-20260810/source-preview";
await fs.mkdir(outputDir, { recursive: true });
const input = await FileBlob.load(inputPath);
const workbook = await SpreadsheetFile.importXlsx(input);
const overview = await workbook.inspect({
kind: "workbook,sheet,table,drawing",
maxChars: 20000,
tableMaxRows: 12,
tableMaxCols: 20,
tableMaxCellChars: 180,
});
await fs.writeFile(path.join(outputDir, "overview.ndjson"), overview.ndjson, "utf8");
const sheets = [];
for (let i = 0; ; i += 1) {
let sheet;
try {
sheet = workbook.worksheets.getItemAt(i);
} catch {
break;
}
if (!sheet) break;
const name = sheet.name;
const used = sheet.getUsedRange();
const usedAddress = used?.address ?? null;
const info = await workbook.inspect({
kind: "region,computedStyle,formula",
sheetId: name,
range: usedAddress ?? "A1:Z60",
maxChars: 30000,
tableMaxRows: 80,
tableMaxCols: 30,
tableMaxCellChars: 240,
options: { maxResults: 200 },
});
await fs.writeFile(path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.ndjson`), info.ndjson, "utf8");
const preview = await workbook.render({ sheetName: name, autoCrop: "all", scale: 1, format: "png" });
const previewPath = path.join(outputDir, `${String(i + 1).padStart(2, "0")}-${name.replaceAll(/[\\/:*?\[\]]/g, "_")}.png`);
await fs.writeFile(previewPath, new Uint8Array(await preview.arrayBuffer()));
sheets.push({ index: i, name, usedAddress, previewPath });
}
await fs.writeFile(path.join(outputDir, "sheets.json"), JSON.stringify(sheets, null, 2), "utf8");
console.log(JSON.stringify(sheets, null, 2));
@@ -0,0 +1,28 @@
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
source = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/preview")
output = source / "contact-sheet.jpg"
files = sorted(source.glob("*.png"))
thumb_w, thumb_h = 620, 360
label_h = 34
cols = 2
rows = (len(files) + cols - 1) // cols
canvas = Image.new("RGB", (cols * thumb_w, rows * (thumb_h + label_h)), "white")
draw = ImageDraw.Draw(canvas)
font = ImageFont.load_default()
for index, file in enumerate(files):
image = Image.open(file).convert("RGB")
image.thumbnail((thumb_w - 12, thumb_h - 12))
x0 = (index % cols) * thumb_w
y0 = (index // cols) * (thumb_h + label_h)
x = x0 + (thumb_w - image.width) // 2
y = y0 + 6
canvas.paste(image, (x, y))
draw.rectangle((x0, y0, x0 + thumb_w - 1, y0 + thumb_h + label_h - 1), outline="#B7C9D6", width=1)
draw.text((x0 + 8, y0 + thumb_h + 8), file.stem, fill="#1F2937", font=font)
canvas.save(output, quality=88)
print(output)
@@ -0,0 +1,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import { FileBlob, SpreadsheetFile } from "@oai/artifact-tool";
const workbookPath = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/15辆车里程核验申诉材料_GB32960日里程及原始报文.xlsx";
const outputDir = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/outputs/019fe9b9-eb60-7d22-8c0a-c70851a48083/final-qa";
await fs.mkdir(outputDir, { recursive: true });
const workbook = await SpreadsheetFile.importXlsx(await FileBlob.load(workbookPath));
const sheets = await workbook.inspect({ kind: "sheet", include: "id,name", maxChars: 8000 });
const summary = await workbook.inspect({ kind: "table", range: "申诉总览!A1:P20", include: "values,formulas", tableMaxRows: 22, tableMaxCols: 16, maxChars: 18000 });
const vehicleTop = await workbook.inspect({ kind: "table", range: "02-粤A05839F!A4:P14", include: "values,formulas", tableMaxRows: 14, tableMaxCols: 16, maxChars: 12000 });
const errors = await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A", options: { useRegex: true, maxResults: 300 }, summary: "reimported final formula error scan" });
await fs.writeFile(path.join(outputDir, "final-inspect.ndjson"), `${sheets.ndjson}\n${summary.ndjson}\n${vehicleTop.ndjson}\n${errors.ndjson}\n`, "utf8");
const vehicleSheetNames = [
"01-粤A05995F", "02-粤A05839F", "03-粤A09369F", "04-粤A06658F", "05-粤A05700F",
"06-粤A05906F", "07-粤AGP9346", "08-粤AGP2017", "09-粤AGP5165", "10-粤AGP4355",
"11-粤AGP5646", "12-粤AGP3692", "13-粤AGP9782", "14-粤AGP3027", "15-粤AGP9751",
];
for (let index = 0; index < vehicleSheetNames.length; index += 1) {
const sheetName = vehicleSheetNames[index];
const preview = await workbook.render({ sheetName, range: "A232:P259", scale: 1, format: "png" });
await fs.writeFile(path.join(outputDir, `${String(index + 1).padStart(2, "0")}-${sheetName}.png`), new Uint8Array(await preview.arrayBuffer()));
}
console.log(JSON.stringify({ workbookPath, outputDir, sheetCount: 17, formulaErrorScan: errors.ndjson }, null, 2));
+17
View File
@@ -0,0 +1,17 @@
import pymysql, pathlib, re, json
def connect():
env={}
for name in ['base.env','stat-writer.env']:
for line in pathlib.Path('/opt/lingniu-go-native/env/'+name).read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);env[k]=v.strip().strip('\"').strip("'")
m=re.fullmatch(r'([^:]+):(.*?)@tcp\(([^:]+):(\d+)\)/([^?]+).*',env['MYSQL_DSN'])
assert m, 'Unexpected DSN format'
return pymysql.connect(user=m[1],password=m[2],host=m[3],port=int(m[4]),database=m[5],charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False)
if __name__=='__main__':
c=connect()
with c.cursor() as q:
for table in ['vehicle_daily_mileage_source','vehicle_daily_mileage','vehicle_data_source','vehicle_identifier','vehicle']:
q.execute('SHOW COLUMNS FROM '+table); print(table,json.dumps(q.fetchall(),default=str,ensure_ascii=False))
q.execute("SELECT * FROM vehicle_data_source WHERE platform_name LIKE '%赛格%' OR source_code LIKE '%seg%' OR source_ip LIKE '%seg%'"); print('SEG_SOURCES',json.dumps(q.fetchall(),default=str,ensure_ascii=False))
c.close()
+80
View File
@@ -0,0 +1,80 @@
from db import connect
import json, pathlib, collections, sys, decimal
root=pathlib.Path(__file__).parent
rows=json.loads((root/'rows.json').read_text())
c=connect(); q=c.cursor()
def query(sql,args=()):
q.execute(sql,args);return q.fetchall()
maps=collections.defaultdict(set)
for r in query("SELECT vin,plate FROM vehicle UNION SELECT vin,plate FROM vehicle_identifier WHERE enabled=1 UNION SELECT vin,plate FROM vehicle_identity_binding"):
if r['plate'] and r['vin']:maps[r['plate'].strip()].add(r['vin'])
unmapped=[]; mapped=[]
for r in rows:
vins=maps[r['plate']]
if len(vins)!=1:unmapped.append(dict(r,matching_vins=sorted(vins)));continue
mapped.append(dict(r,vin=next(iter(vins))))
assert len({(r['vin'],r['date']) for r in mapped})==len(mapped),'VIN/date collision'
source_ids=[r['id'] for r in query("SELECT id FROM vehicle_data_source WHERE protocol='JT808' AND (source_code='saige' OR platform_name LIKE '%%赛格%%')")]
existing=query("SELECT s.* FROM vehicle_daily_mileage_source s LEFT JOIN vehicle_data_source d ON d.protocol=s.protocol AND d.source_ip=s.source_ip WHERE s.protocol='JT808' AND s.stat_date BETWEEN '2026-07-01' AND '2026-08-31' AND (d.source_code='saige' OR d.platform_name LIKE '%%赛格%%' OR s.platform_name LIKE '%%赛格%%' OR s.source_key LIKE '%%saige%%')")
finals=query("SELECT * FROM vehicle_daily_mileage WHERE protocol='JT808' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
keys={(r['vin'],str(r['stat_date'])) for r in existing}
keys.update((r['vin'],str(r['stat_date'])) for r in finals if r['source_id'] in source_ids)
pending=[r for r in mapped if (r['vin'],r['date']) not in keys]
summary=dict(input_rows=len(rows),mapped_rows=len(mapped),unmapped_rows=len(unmapped),unmapped_plates=sorted({r['plate'] for r in unmapped}),existing_saige_skipped=len(mapped)-len(pending),insert_candidates=len(pending),insert_vehicles=len({r['vin'] for r in pending}),insert_total_km=str(sum((decimal.Decimal(r['km']) for r in pending),decimal.Decimal(0))))
(root/'preflight.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
(root/'unmapped.json').write_text(json.dumps(unmapped,ensure_ascii=False,indent=2))
(root/'pending.json').write_text(json.dumps(pending,ensure_ascii=False,indent=2))
print(json.dumps(summary,ensure_ascii=False))
if '--apply' not in sys.argv:
c.rollback(); c.close(); sys.exit(0)
# Historical dates only. Lock every affected vehicle/day source range and result
# in deterministic order before rechecking existence. No UPDATE statements.
c.rollback()
pending.sort(key=lambda r:(r['vin'],r['date']))
before=[]; locked=[]; skipped_race=0
for r in pending:
args=(r['vin'],r['date'])
ss=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
ff=query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808' FOR UPDATE",args)
before.append(dict(vin=r['vin'],date=r['date'],sources=ss,final=ff))
has=any(s['source_ip'] in ('222.66.200.68','58.33.87.196','manual-saige-excel') or 'saige' in s['source_key'].lower() or '赛格' in (s['platform_name'] or '') for s in ss) or any(f['source_id'] in source_ids for f in ff)
if has:skipped_race+=1;continue
locked.append((r,ff))
(root/'backup-before.json').write_text(json.dumps(before,default=str,ensure_ascii=False))
ds=query("SELECT * FROM vehicle_data_source WHERE protocol='JT808' AND source_ip='manual-saige-excel'")
if ds:
assert ds[0]['source_code']=='saige' and ds[0]['platform_name']=='赛格'
sid=ds[0]['id']
else:
q.execute("INSERT INTO vehicle_data_source (protocol,source_ip,platform_name,source_code,source_kind,trust_priority,enabled,remark) VALUES ('JT808','manual-saige-excel','赛格','saige','PLATFORM',100,1,%s)",('赛格Excel每日里程导入;仅补缺,不覆盖已有记录;报表未提供累计总里程',))
sid=q.lastrowid
inserted=[]; final_inserted=0
for r,ff in locked:
key='JT808:'+r['phone']+'@PLATFORM:saige_excel'
reason='manual_saige_excel_import_20260907;file='+r['file']+';row='+str(r['row'])
q.execute("""INSERT INTO vehicle_daily_mileage_source
(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected)
VALUES (%s,%s,'JT808',%s,'manual-saige-excel','manual-excel-import',%s,'赛格',%s,1,'OK',%s,%s)""",(r['vin'],r['date'],key,r['phone'],r['km'],reason,int(not ff)))
assert q.rowcount==1
if not ff:
q.execute("INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (%s,%s,'JT808',%s,%s)",(r['vin'],r['date'],sid,r['km']))
assert q.rowcount==1;final_inserted+=1
inserted.append(dict(r,source_key=key))
# Verify every old row is byte-for-byte equivalent as typed query results.
for b in before:
args=(b['vin'],b['date'])
now=query("SELECT * FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)
lookup={s['source_key']:s for s in now}
assert all(lookup[s['source_key']]==s for s in b['sources']), 'Existing source changed'
if b['final']:
assert query("SELECT * FROM vehicle_daily_mileage WHERE vin=%s AND stat_date=%s AND protocol='JT808'",args)==b['final'],'Existing final changed'
for r in inserted:
v=query("SELECT daily_mileage_km,latest_total_mileage_km,first_total_mileage_km FROM vehicle_daily_mileage_source WHERE vin=%s AND stat_date=%s AND protocol='JT808' AND source_key=%s",(r['vin'],r['date'],r['source_key']))
assert len(v)==1 and v[0]['daily_mileage_km']==decimal.Decimal(r['km']) and v[0]['latest_total_mileage_km'] is None and v[0]['first_total_mileage_km'] is None
result=dict(summary,inserted_sources=len(inserted),inserted_final_rows=final_inserted,preserved_existing_final_rows=len(locked)-final_inserted,concurrent_skipped=skipped_race,source_id=sid,existing_rows_verified_unchanged=True)
(root/'inserted.json').write_text(json.dumps(inserted,ensure_ascii=False))
c.commit()
result['committed']=True
(root/'result.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps(result,ensure_ascii=False))
c.close()
@@ -0,0 +1,25 @@
import xlrd, json, pathlib, decimal, hashlib, re
out=pathlib.Path(__file__).parent
rows=[]; summary=[]; seen={}
for p in sorted(pathlib.Path('/Users/lingniu/Documents').glob('里程统计报表*.xls')):
s=xlrd.open_workbook(str(p)).sheet_by_index(0)
meta={'file':p.name,'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'rows':s.nrows-1}
if s.cell_value(0,2)!='日期':
meta.update(excluded='No daily date column',nonzero=sum(decimal.Decimal(str(s.cell_value(i,3)))!=0 for i in range(1,s.nrows)))
summary.append(meta); continue
dates=[]
for i in range(1,s.nrows):
plate,phone,day,km=map(lambda x:str(x).strip(),s.row_values(i))
import datetime
datetime.date.fromisoformat(day)
assert re.fullmatch(r'(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?',km), km
amount=decimal.Decimal(km.replace(',','')); assert amount.is_finite() and amount>=0
key=(plate,day)
assert key not in seen, ('duplicate',key)
seen[key]=amount; dates.append(day)
rows.append(dict(plate=plate,phone=phone,date=day,km=str(amount),file=p.name,row=i+1))
meta.update(date_min=min(dates),date_max=max(dates),vehicles=len({r['plate'] for r in rows if r['file']==p.name}))
summary.append(meta)
(out/'rows.json').write_text(json.dumps(rows,ensure_ascii=False))
(out/'input-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
print(json.dumps(summary,ensure_ascii=False,indent=2))
+15
View File
@@ -0,0 +1,15 @@
from db import connect
import pathlib,json,decimal,collections
p=pathlib.Path(__file__).parent
inserted=json.loads((p/'inserted.json').read_text())
c=connect();q=c.cursor()
q.execute("SELECT vin,stat_date,source_key,daily_mileage_km,latest_total_mileage_km FROM vehicle_daily_mileage_source WHERE source_ip='manual-saige-excel' AND stat_date BETWEEN '2026-07-01' AND '2026-08-31'")
actual={(r['vin'],str(r['stat_date']),r['source_key']):r for r in q.fetchall()}
months=collections.defaultdict(lambda:dict(rows=0,total_km=decimal.Decimal(0)))
for r in inserted:
a=actual[(r['vin'],r['date'],r['source_key'])]
assert a['daily_mileage_km']==decimal.Decimal(r['km']) and a['latest_total_mileage_km'] is None
m=months[r['date'][:7]];m['rows']+=1;m['total_km']+=a['daily_mileage_km']
v=dict(verified_rows=len(inserted),months=dict(months),verified_total_km=sum((m['total_km'] for m in months.values()),decimal.Decimal(0)))
(p/'verification.json').write_text(json.dumps(v,default=str,ensure_ascii=False,indent=2))
print(json.dumps(v,default=str,ensure_ascii=False));c.close()
@@ -0,0 +1,638 @@
import fs from "node:fs/promises";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const workspaceRoot = "/Users/lingniu/project/ai-coding/lingniu-vehicle-ingest";
const workDir = path.join(
workspaceRoot,
"tmp/vehicle-mileage-selection-019fa1ac-db39-7933-bb0e-30dd63cc25bd",
);
const outputDir = path.join(
workspaceRoot,
"outputs/019fa1ac-db39-7933-bb0e-30dd63cc25bd",
);
const outputPath = path.join(
outputDir,
"江浙沪四类车型近三个月GPS里程清单_20260427-20260726.xlsx",
);
const masterPath = path.join(
workspaceRoot,
"outputs/jt808-provider-audit-20260717/oneos-vehicle-master.json",
);
const dateFrom = "2026-04-27";
const dateTo = "2026-07-26";
const periodDays = 91;
const queryAsOf = "2026-07-28";
const mileageProtocol = "JT808";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function postProductionAPI(endpoint, body) {
const remoteCommand =
`. /opt/lingniu-vehicle-platform/env/access-tokens.env; ` +
`curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" ` +
`-H "Content-Type: application/json" --data-binary @- ` +
`http://127.0.0.1:20300${endpoint}`;
const result = spawnSync(
"ssh",
["-o", "BatchMode=yes", "root@115.29.187.205", remoteCommand],
{
input: JSON.stringify(body),
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
},
);
if (result.status !== 0) {
throw new Error(
`Production API failed for ${endpoint}: ${result.stderr || result.stdout}`,
);
}
const payload = JSON.parse(result.stdout);
if (!payload.data) {
throw new Error(`Production API returned no data for ${endpoint}`);
}
return payload.data;
}
function excelDate(isoDate) {
return new Date(`${isoDate}T00:00:00+08:00`);
}
function datesBetween(start, end) {
const result = [];
const cursor = new Date(`${start}T00:00:00+08:00`);
const last = new Date(`${end}T00:00:00+08:00`);
while (cursor <= last) {
result.push(cursor.toLocaleDateString("en-CA", { timeZone: "Asia/Shanghai" }));
cursor.setDate(cursor.getDate() + 1);
}
return result;
}
function residentLocation(rawCity) {
const parts = String(rawCity || "").split("-");
const province = parts[0] || "";
const city = parts.slice(1).join("-") || province;
return { province, city };
}
function vehicleCategory(model) {
const normalized = String(model || "");
if (normalized.includes("4.5吨") && !normalized.includes("冷链")) {
return "4.5T普货";
}
if (normalized.includes("冷链")) return "冷链";
if (normalized.includes("18吨")) return "18T";
if (normalized.includes("49吨")) return "49T";
return "";
}
function isJiangZheHu(city) {
return ["江苏省-", "浙江省-", "上海市-"].some((prefix) =>
String(city || "").startsWith(prefix),
);
}
async function loadMasterVehicles() {
const source = JSON.parse(await fs.readFile(masterPath, "utf8"));
const rows = source.sheets?.[0]?.rows || [];
assert(rows.length > 1, "OneOS vehicle master is empty");
const headers = rows[0].values;
const headerIndex = Object.fromEntries(headers.map((value, index) => [value, index]));
const requiredHeaders = [
"车牌号",
"VIN",
"运营城市",
"品牌",
"型号",
"运营状态",
"车辆状态",
"客户名称",
];
for (const header of requiredHeaders) {
assert(headerIndex[header] !== undefined, `Missing master header: ${header}`);
}
const result = [];
for (const row of rows.slice(1)) {
const values = [...row.values, ...Array(headers.length).fill("")];
const cityRaw = values[headerIndex["运营城市"]];
const model = values[headerIndex["型号"]];
const category = vehicleCategory(model);
if (!category || !isJiangZheHu(cityRaw)) continue;
const vin = String(values[headerIndex["VIN"]] || "").trim();
const plate = String(values[headerIndex["车牌号"]] || "").trim();
if (!vin || !plate) continue;
const location = residentLocation(cityRaw);
result.push({
category,
plate,
vin,
brand: String(values[headerIndex["品牌"]] || "").trim(),
model: String(model || "").trim(),
province: location.province,
city: location.city,
cityRaw: String(cityRaw || "").trim(),
operationStatus: String(values[headerIndex["运营状态"]] || "").trim(),
vehicleStatus: String(values[headerIndex["车辆状态"]] || "").trim(),
customerName: ["租赁", "自营"].includes(
String(values[headerIndex["运营状态"]] || "").trim(),
)
? String(values[headerIndex["客户名称"]] || "").trim()
: "",
});
}
return result;
}
function queryRanking(vins) {
if (vins.length === 0) return { ranking: [], asOf: "" };
return postProductionAPI("/api/v2/statistics/mileage", {
dateFrom,
dateTo,
vins,
protocol: mileageProtocol,
});
}
function rankingMap(data) {
return new Map((data.ranking || []).map((row) => [row.vin, row]));
}
function selectCategoryVehicles(category, candidates) {
const categoryCandidates = candidates.filter((row) => row.category === category);
assert(
categoryCandidates.length >= 10,
`${category} has only ${categoryCandidates.length} Jiang-Zhe-Hu candidates`,
);
const masterByVIN = new Map(categoryCandidates.map((row) => [row.vin, row]));
const suzhouVINs = categoryCandidates
.filter((row) => row.city === "苏州市")
.map((row) => row.vin);
const otherVINs = categoryCandidates
.filter((row) => row.city !== "苏州市")
.map((row) => row.vin);
const suzhouRanking = queryRanking(suzhouVINs);
const otherRanking = queryRanking(otherVINs);
const combined = [
...(suzhouRanking.ranking || []).map((row) => ({
...row,
suzhouPreferred: true,
})),
...(otherRanking.ranking || []).map((row) => ({
...row,
suzhouPreferred: false,
})),
];
assert(combined.length >= 10, `${category} has fewer than 10 vehicles with mileage`);
return combined.slice(0, 10).map((ranked, index) => ({
...masterByVIN.get(ranked.vin),
rank: index + 1,
sourcePeriodMileageKm: Number(ranked.mileageKm || 0),
sourceActiveDays: Number(ranked.activeDays || 0),
suzhouPreferred: ranked.suzhouPreferred,
statisticsAsOf: suzhouRanking.asOf || otherRanking.asOf || "",
}));
}
function queryDailyMileage(vins) {
const items = [];
let offset = 0;
const limit = 1000;
let total = Number.POSITIVE_INFINITY;
while (offset < total) {
const page = postProductionAPI("/api/mileage/daily", {
dateFrom,
dateTo,
vins,
protocol: mileageProtocol,
deduplicate: true,
limit,
offset,
});
const pageItems = page.items || [];
items.push(...pageItems);
total = Number(page.total || 0);
offset += pageItems.length;
if (pageItems.length === 0) break;
}
return items;
}
function styleTitle(sheet, rangeAddress, title) {
const range = sheet.getRange(rangeAddress);
range.merge();
range.values = [[title]];
range.format = {
fill: "#0F4C5C",
font: { bold: true, color: "#FFFFFF", size: 16 },
verticalAlignment: "center",
horizontalAlignment: "left",
};
range.format.rowHeight = 34;
}
function styleHeader(range) {
range.format = {
fill: "#DCEEF2",
font: { bold: true, color: "#12343B" },
verticalAlignment: "center",
horizontalAlignment: "center",
wrapText: true,
borders: {
bottom: { style: "medium", color: "#78A7B2" },
},
};
range.format.rowHeight = 30;
}
function styleNote(range) {
range.format = {
fill: "#F3F8FA",
font: { color: "#385A64", size: 10 },
wrapText: true,
verticalAlignment: "center",
};
}
function addCategoryConditionalFormatting(range) {
range.conditionalFormats.addCustom('=$A5="4.5T普货"', {
fill: "#FFF4CC",
font: { bold: true, color: "#7A5300" },
});
range.conditionalFormats.addCustom('=$A5="冷链"', {
fill: "#DDF3FF",
font: { bold: true, color: "#075985" },
});
range.conditionalFormats.addCustom('=$A5="18T"', {
fill: "#E8E1FF",
font: { bold: true, color: "#5B21B6" },
});
range.conditionalFormats.addCustom('=$A5="49T"', {
fill: "#DFF5E6",
font: { bold: true, color: "#166534" },
});
}
const masterVehicles = await loadMasterVehicles();
const categories = ["4.5T普货", "冷链", "18T", "49T"];
const selectedVehicles = categories.flatMap((category) =>
selectCategoryVehicles(category, masterVehicles),
);
assert(selectedVehicles.length === 40, "Expected exactly 40 selected vehicles");
assert(
new Set(selectedVehicles.map((row) => row.vin)).size === 40,
"Selected VINs are not unique",
);
const dailySourceRows = queryDailyMileage(selectedVehicles.map((row) => row.vin));
const dailyByVehicleDate = new Map();
for (const row of dailySourceRows) {
assert(
row.source === mileageProtocol,
`Unexpected mileage source for ${row.vin} on ${row.date}: ${row.source}`,
);
const key = `${row.vin}|${row.date}`;
assert(!dailyByVehicleDate.has(key), `Duplicate deduplicated daily row: ${key}`);
dailyByVehicleDate.set(key, row);
}
const dates = datesBetween(dateFrom, dateTo);
assert(dates.length === periodDays, `Expected ${periodDays} dates, got ${dates.length}`);
const dailyRows = [];
for (const vehicle of selectedVehicles) {
for (const date of dates) {
const source = dailyByVehicleDate.get(`${vehicle.vin}|${date}`);
dailyRows.push({
date,
...vehicle,
dailyMileageKm: source ? Number(source.dailyMileageKm || 0) : null,
source: source?.source || "",
recordStatus: source ? "有记录" : "缺失",
});
}
}
assert(dailyRows.length === 40 * periodDays, "Daily row count mismatch");
for (const vehicle of selectedVehicles) {
const computed = dailyRows
.filter((row) => row.vin === vehicle.vin)
.reduce((sum, row) => sum + (row.dailyMileageKm ?? 0), 0);
const difference = Math.abs(computed - vehicle.sourcePeriodMileageKm);
assert(
difference < 0.02,
`${vehicle.plate} total mismatch: daily=${computed}, stats=${vehicle.sourcePeriodMileageKm}`,
);
}
const workbook = Workbook.create();
const notesSheet = workbook.worksheets.add("口径说明");
const summarySheet = workbook.worksheets.add("车辆汇总");
const dailySheet = workbook.worksheets.add("每日里程");
for (const sheet of [notesSheet, summarySheet, dailySheet]) {
sheet.showGridLines = false;
}
// 口径说明
styleTitle(notesSheet, "A1:H1", "江浙沪四类车型近三个月 GPS 里程清单");
notesSheet.getRange("A3:B11").values = [
["项目", "口径"],
["统计区间", `${dateFrom}${dateTo}${periodDays} 个自然日,完整日)`],
["区域范围", "常驻市(OneOS 运营城市)属于江苏、浙江、上海"],
["选车规则", "每类先按苏州区间总里程降序选取,不足 10 辆时按江浙沪其他城市区间总里程降序补齐"],
["车型定义", "4.5T普货=型号含4.5吨且不含冷链;冷链=型号含冷链;18T=型号含18吨;49T=型号含49吨"],
["里程口径", "仅使用 JT808 定位终端/GPS 侧累计里程计算,不使用 GB32960、宇通 MQTT 等仪表/车端累计里程"],
["缺失处理", "逐日表保留全部车辆×日期组合;无生产记录的日期留空,并标记为“缺失”,不擅自按0计算"],
["主数据快照", "OneOS车辆信息快照:2026-07-17;常驻市取“运营城市”字段"],
["里程数据截至", `${queryAsOf} 查询,统计截止至 ${dateTo};来源协议固定为 ${mileageProtocol}`],
];
styleHeader(notesSheet.getRange("A3:B3"));
styleNote(notesSheet.getRange("A4:B11"));
notesSheet.getRange("A4:A11").format.font = { bold: true, color: "#12343B" };
notesSheet.getRange("A3:B11").format.borders = {
outside: { style: "thin", color: "#B9D3DA" },
insideHorizontal: { style: "thin", color: "#DCE8EC" },
};
notesSheet.getRange("A13:E18").values = [
["分类", "车辆数", "苏州优先数", "GPS区间总里程(km)", "GPS日均里程(km/天)"],
["4.5T普货", null, null, null, null],
["冷链", null, null, null, null],
["18T", null, null, null, null],
["49T", null, null, null, null],
["合计", null, null, null, null],
];
styleHeader(notesSheet.getRange("A13:E13"));
notesSheet.getRange("B14").formulas = [["=COUNTIF('车辆汇总'!$A$5:$A$44,A14)"]];
notesSheet.getRange("B14:B17").fillDown();
notesSheet.getRange("C14").formulas = [
["=COUNTIFS('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$I$5:$I$44,\"苏州市\")"],
];
notesSheet.getRange("C14:C17").fillDown();
notesSheet.getRange("D14").formulas = [
["=SUMIF('车辆汇总'!$A$5:$A$44,A14,'车辆汇总'!$O$5:$O$44)"],
];
notesSheet.getRange("D14:D17").fillDown();
notesSheet.getRange("E14").formulas = [["=D14/(B14*'车辆汇总'!$N$5)"]];
notesSheet.getRange("E14:E17").fillDown();
notesSheet.getRange("B18").formulas = [["=SUM(B14:B17)"]];
notesSheet.getRange("C18").formulas = [["=SUM(C14:C17)"]];
notesSheet.getRange("D18").formulas = [["=SUM(D14:D17)"]];
notesSheet.getRange("E18").formulas = [["=D18/(B18*'车辆汇总'!$N$5)"]];
notesSheet.getRange("D14:E18").format.numberFormat = "#,##0.0";
notesSheet.getRange("A18:E18").format = {
fill: "#E8F1F4",
font: { bold: true, color: "#12343B" },
borders: { top: { style: "double", color: "#78A7B2" } },
};
notesSheet.getRange("A13:E18").format.borders = {
outside: { style: "thin", color: "#B9D3DA" },
insideHorizontal: { style: "thin", color: "#DCE8EC" },
};
notesSheet.getRange("A:A").format.columnWidth = 18;
notesSheet.getRange("B:B").format.columnWidth = 64;
notesSheet.getRange("C:E").format.columnWidth = 18;
notesSheet.getRange("A4:B11").format.rowHeight = 34;
notesSheet.freezePanes.freezeRows(3);
// 车辆汇总
styleTitle(summarySheet, "A1:Q1", "四类车型各10辆|近三个月 GPS 里程汇总");
summarySheet.getRange("A2:Q2").merge();
summarySheet.getRange("A2").values = [[
`范围:江浙沪常驻车辆;苏州优先;统计区间 ${dateFrom}${dateTo}。仅使用 JT808 GPS 里程,区间总里程由“每日里程”明细公式汇总。`,
]];
styleNote(summarySheet.getRange("A2:Q2"));
const summaryHeaders = [
"分类",
"类内排名",
"车牌",
"VIN",
"品牌",
"型号",
"常驻省",
"常驻城市原值",
"常驻市",
"苏州优先",
"运营状态",
"车辆状态",
"客户名称",
"区间天数",
"GPS区间总里程(km)",
"GPS日均里程(km/天)",
"有记录天数",
];
summarySheet.getRange("A4:Q4").values = [summaryHeaders];
styleHeader(summarySheet.getRange("A4:Q4"));
const summaryValues = selectedVehicles.map((vehicle) => [
vehicle.category,
vehicle.rank,
vehicle.plate,
vehicle.vin,
vehicle.brand,
vehicle.model,
vehicle.province,
vehicle.cityRaw,
vehicle.city,
vehicle.suzhouPreferred ? "是" : "否",
vehicle.operationStatus,
vehicle.vehicleStatus,
vehicle.customerName,
periodDays,
null,
null,
null,
]);
summarySheet.getRange(`A5:Q${4 + summaryValues.length}`).values = summaryValues;
const dailyEndRow = 4 + dailyRows.length;
summarySheet.getRange("O5").formulas = [[
`=SUMIFS('每日里程'!$J$5:$J$${dailyEndRow},'每日里程'!$E$5:$E$${dailyEndRow},D5)`,
]];
summarySheet.getRange("O5:O44").fillDown();
summarySheet.getRange("P5").formulas = [["=O5/N5"]];
summarySheet.getRange("P5:P44").fillDown();
summarySheet.getRange("Q5").formulas = [[
`=COUNTIFS('每日里程'!$E$5:$E$${dailyEndRow},D5,'每日里程'!$L$5:$L$${dailyEndRow},"有记录")`,
]];
summarySheet.getRange("Q5:Q44").fillDown();
summarySheet.getRange("B5:B44").format.numberFormat = "0";
summarySheet.getRange("N5:N44").format.numberFormat = "0";
summarySheet.getRange("O5:P44").format.numberFormat = "#,##0.0";
summarySheet.getRange("Q5:Q44").format.numberFormat = "0";
summarySheet.getRange("A5:Q44").format.verticalAlignment = "center";
summarySheet.getRange("A5:Q44").format.borders = {
insideHorizontal: { style: "thin", color: "#E2ECEF" },
};
addCategoryConditionalFormatting(summarySheet.getRange("A5:A44"));
summarySheet.getRange("O5:O44").conditionalFormats.add("dataBar", {
color: "#2A9D8F",
gradient: true,
});
summarySheet.getRange("J5:J44").conditionalFormats.add("containsText", {
text: "是",
format: {
fill: "#E5F7ED",
font: { bold: true, color: "#166534" },
},
});
const summaryTable = summarySheet.tables.add("A4:Q44", true, "VehicleMileageSummary");
summaryTable.style = "TableStyleMedium2";
summaryTable.showFilterButton = true;
summarySheet.freezePanes.freezeRows(4);
summarySheet.freezePanes.freezeColumns(4);
const summaryWidths = [
14, 10, 14, 22, 12, 30, 12, 20, 12, 10, 12, 12, 30, 10, 18, 18, 14,
];
summaryWidths.forEach((width, index) => {
summarySheet.getRangeByIndexes(0, index, 44, 1).format.columnWidth = width;
});
summarySheet.getRange("A5:Q44").format.rowHeight = 23;
// 每日里程
styleTitle(dailySheet, "A1:L1", "40辆车逐日 GPS 里程明细");
dailySheet.getRange("A2:L2").merge();
dailySheet.getRange("A2").values = [[
`${selectedVehicles.length}× ${periodDays} 天 = ${dailyRows.length.toLocaleString("zh-CN")} 行;仅统计 JT808 GPS 里程,缺失日期留空并单独标记。`,
]];
styleNote(dailySheet.getRange("A2:L2"));
const dailyHeaders = [
"日期",
"分类",
"类内排名",
"车牌",
"VIN",
"品牌",
"型号",
"常驻市",
"客户名称",
"GPS每日里程(km)",
"来源",
"记录状态",
];
dailySheet.getRange("A4:L4").values = [dailyHeaders];
styleHeader(dailySheet.getRange("A4:L4"));
const dailyValues = dailyRows.map((row) => [
excelDate(row.date),
row.category,
row.rank,
row.plate,
row.vin,
row.brand,
row.model,
row.city,
row.customerName,
row.dailyMileageKm,
row.source,
row.recordStatus,
]);
dailySheet.getRange(`A5:L${dailyEndRow}`).values = dailyValues;
dailySheet.getRange(`A5:A${dailyEndRow}`).format.numberFormat = "yyyy-mm-dd";
dailySheet.getRange(`C5:C${dailyEndRow}`).format.numberFormat = "0";
dailySheet.getRange(`J5:J${dailyEndRow}`).format.numberFormat = "#,##0.0";
dailySheet.getRange(`A5:L${dailyEndRow}`).format.borders = {
insideHorizontal: { style: "thin", color: "#EDF2F4" },
};
dailySheet.getRange(`L5:L${dailyEndRow}`).conditionalFormats.add("containsText", {
text: "缺失",
format: {
fill: "#FFF0F0",
font: { bold: true, color: "#B42318" },
},
});
dailySheet.getRange(`J5:J${dailyEndRow}`).conditionalFormats.add("dataBar", {
color: "#5CA4A9",
gradient: true,
});
const dailyTable = dailySheet.tables.add(
`A4:L${dailyEndRow}`,
true,
"VehicleDailyMileage",
);
dailyTable.style = "TableStyleMedium2";
dailyTable.showFilterButton = true;
dailySheet.freezePanes.freezeRows(4);
dailySheet.freezePanes.freezeColumns(5);
const dailyWidths = [14, 14, 10, 14, 22, 12, 30, 12, 30, 18, 16, 12];
dailyWidths.forEach((width, index) => {
dailySheet.getRangeByIndexes(0, index, dailyEndRow, 1).format.columnWidth = width;
});
dailySheet.getRange(`A5:L${dailyEndRow}`).format.rowHeight = 21;
// Compact verification before export.
const summaryInspect = await workbook.inspect({
kind: "table",
range: "车辆汇总!A1:Q16",
include: "values,formulas",
tableMaxRows: 16,
tableMaxCols: 17,
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "summary-inspect.ndjson"), summaryInspect.ndjson);
const dailyInspect = await workbook.inspect({
kind: "table",
range: "每日里程!A1:L20",
include: "values,formulas",
tableMaxRows: 20,
tableMaxCols: 12,
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "daily-inspect.ndjson"), dailyInspect.ndjson);
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",
maxChars: 12000,
});
await fs.writeFile(path.join(workDir, "formula-errors.ndjson"), formulaErrors.ndjson);
for (const [sheetName, range, fileName] of [
["口径说明", "A1:H19", "preview-notes.png"],
["车辆汇总", "A1:Q18", "preview-summary.png"],
["每日里程", "A1:L26", "preview-daily.png"],
]) {
const preview = await workbook.render({
sheetName,
range,
scale: 1.25,
format: "png",
});
await fs.writeFile(
path.join(workDir, fileName),
new Uint8Array(await preview.arrayBuffer()),
);
}
await fs.mkdir(outputDir, { recursive: true });
const output = await SpreadsheetFile.exportXlsx(workbook);
await output.save(outputPath);
console.log(
JSON.stringify(
{
outputPath,
selected: selectedVehicles.map((vehicle) => ({
category: vehicle.category,
rank: vehicle.rank,
plate: vehicle.plate,
vin: vehicle.vin,
brand: vehicle.brand,
model: vehicle.model,
residentCity: vehicle.city,
suzhouPreferred: vehicle.suzhouPreferred,
periodMileageKm: vehicle.sourcePeriodMileageKm,
})),
sourceDailyRows: dailySourceRows.length,
expandedDailyRows: dailyRows.length,
missingDailyRows: dailyRows.filter((row) => row.recordStatus === "缺失").length,
customerFilledVehicles: selectedVehicles.filter((row) => row.customerName).length,
},
null,
2,
),
);