Files

639 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
);