chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user