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); }