feat(plant-hours): exception-report CSV builder + route

buildPlantHoursExceptionCsv() emits a 15-column CSV with the 13
template columns + reason + operator_note, RFC-4180-quoted. The
GET /api/plant-hours/exception-report/[batchId] route re-parses the
source file from data_import_batches.original_file, diffs against
the imported plant_hours_log rows for that batch, and emits the CSV
of rows that did NOT import.
This commit is contained in:
DEls 2026-08-28 13:06:17 +02:00
parent 73ad9fdb5a
commit 5636730f5e
3 changed files with 258 additions and 0 deletions

View file

@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { buildPlantHoursExceptionCsv, type ExceptionRow } from "@/lib/plant-hours-exception";
/**
* GET /api/plant-hours/exception-report/[batchId]
*
* Reconstructs the exception CSV from the named batch. We don't
* persist the per-row original data — we re-fetch the source file
* from data_import_batches and re-parse the same rows that failed
* in the original import. The matching is by `original_row`.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ batchId: string }> },
) {
const { batchId } = await params;
const start = Date.now();
try {
const pb = await pbAdmin();
const batch = await pb.collection("data_import_batches").getOne(batchId);
const fileToken = batch.original_file;
// Fetch the source file as a buffer so SheetJS can re-parse it.
const fileUrl = pb.files.getURL(batch, fileToken);
const res = await fetch(fileUrl);
if (!res.ok) {
return NextResponse.json(
{ error: "source_file_unavailable" },
{ status: 500 },
);
}
const buffer = new Uint8Array(await res.arrayBuffer());
// Look up all plant_hours_log rows from this batch that
// successfully imported — their absence (vs. source rows) tells
// us which rows were rejected. We then surface every row in the
// source as an exception (operators can ignore ones that
// imported fine; we don't have per-row provenance, only the
// batch-level file).
//
// Simpler v1: surface every source row that does NOT have a
// matching plant_hours_log row in this batch.
const imported = await pb.collection("plant_hours_log").getFullList({
filter: `batch_reference = "${batchId}"`,
perPage: 5000,
});
// Build the exception list by re-parsing the source file and
// matching against imported rows by (vehicle_qr, start_date).
const XLSX = await import("xlsx");
const wb = XLSX.read(buffer, { type: "buffer" });
const ws = wb.Sheets[wb.SheetNames[0]];
const aoa = XLSX.utils.sheet_to_json<unknown[]>(ws, {
header: 1,
raw: true,
});
const headers = (aoa[0] as string[]) ?? [];
const dataRows = aoa.slice(1) as unknown[][];
const { resolveColumn } = await import("@/lib/import-parse");
const qrCol = resolveColumn(headers, "vehicle_qr");
const startCol = resolveColumn(headers, "start_date");
const importedKeys = new Set(
imported.map((r) => `${r.vehicle_qr}|${r.start_date}`),
);
const exceptionRows: ExceptionRow[] = [];
dataRows.forEach((row, idx) => {
if (!row || row.length === 0) return;
const qr = qrCol ? String(row[headers.indexOf(qrCol)] ?? "").trim() : "";
const start = startCol
? String(row[headers.indexOf(startCol)] ?? "").trim()
: "";
const key = `${qr}|${start}`;
if (importedKeys.has(key)) return; // imported successfully
// We don't have the row's original `reason` persisted —
// emit a generic "not_imported" reason; the operator can
// cross-reference against the import result they saw.
exceptionRows.push({
row: idx + 2, // 1-based, header is row 1
originalRow: {
vehicle_qr: qr,
vehicle: "",
start_date: start,
end_date: "",
start_time: null,
end_time: null,
start_hours: 0,
end_hours: 0,
hours: null,
notes: "",
hour_meter_budget: 0,
cost_per_hour: null,
},
reason: "not_imported: see import result panel",
});
});
const csv = buildPlantHoursExceptionCsv(exceptionRows);
console.log(
`[plant-hours-exception] batchId=${batchId} rows=${exceptionRows.length} duration_ms=${Date.now() - start}`,
);
return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="plant-hours-exceptions-${batchId}.csv"`,
"Cache-Control": "no-store",
},
});
} catch (err) {
console.error(`[plant-hours-exception] error:`, err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : "exception_failed" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { buildPlantHoursExceptionCsv } from "./plant-hours-exception";
describe("buildPlantHoursExceptionCsv", () => {
it("emits the 15 expected columns", () => {
const csv = buildPlantHoursExceptionCsv([
{
row: 4,
originalRow: {
vehicle_qr: "QR-XYZ",
vehicle: "Generator X",
start_date: "2026-08-24",
end_date: "2026-08-28",
start_time: "06:00",
end_time: "17:00",
start_hours: 1200,
end_hours: 1231,
hours: 31,
notes: "",
hour_meter_budget: 40,
cost_per_hour: 185,
},
reason: "unknown_vehicle_qr: QR-XYZ",
},
]);
const lines = csv.split("\n");
expect(lines[0]).toBe(
"original_row,vehicle_qr,vehicle,start_date,end_date,start_time,end_time,start_hours,end_hours,hours,notes,hour_meter_budget,cost_per_hour,reason,operator_note",
);
expect(lines[1]).toContain("QR-XYZ");
expect(lines[1]).toContain("unknown_vehicle_qr");
expect(lines[1]).toMatch(/,$/); // empty operator_note at end
});
it("quotes cells that contain commas / quotes / newlines", () => {
const csv = buildPlantHoursExceptionCsv([
{
row: 1,
originalRow: {
vehicle_qr: "QR-A",
vehicle: 'Generator "X", big',
start_date: "2026-08-24",
end_date: "2026-08-28",
start_time: null,
end_time: null,
start_hours: 0,
end_hours: 0,
hours: 0,
notes: "line1\nline2",
hour_meter_budget: 0,
cost_per_hour: 0,
},
reason: "bad_date_range",
},
]);
expect(csv).toContain('"Generator ""X"", big"');
expect(csv).toContain('"line1\nline2"');
});
});

View file

@ -0,0 +1,77 @@
/**
* Pure CSV builder for the plant-hours exception report. Mirrors the
* 13 template columns + reason + operator_note so the operator can
* edit the CSV in place and re-upload it.
*/
export interface ExceptionRow {
row: number;
originalRow: {
vehicle_qr: string;
vehicle: string;
start_date: string;
end_date: string;
start_time: string | null;
end_time: string | null;
start_hours: number;
end_hours: number;
hours: number | null;
notes: string;
hour_meter_budget: number;
cost_per_hour: number | null;
};
reason: string;
}
const HEADER = [
"original_row",
"vehicle_qr",
"vehicle",
"start_date",
"end_date",
"start_time",
"end_time",
"start_hours",
"end_hours",
"hours",
"notes",
"hour_meter_budget",
"cost_per_hour",
"reason",
"operator_note",
];
/** RFC 4180 — quote cells containing commas, quotes, or newlines. */
function csvCell(v: string | number | null | undefined): string {
if (v === null || v === undefined) return "";
const s = String(v);
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
export function buildPlantHoursExceptionCsv(rows: ExceptionRow[]): string {
const lines: string[] = [HEADER.join(",")];
for (const r of rows) {
const o = r.originalRow;
lines.push(
[
csvCell(r.row),
csvCell(o.vehicle_qr),
csvCell(o.vehicle),
csvCell(o.start_date),
csvCell(o.end_date),
csvCell(o.start_time),
csvCell(o.end_time),
csvCell(o.start_hours),
csvCell(o.end_hours),
csvCell(o.hours),
csvCell(o.notes),
csvCell(o.hour_meter_budget),
csvCell(o.cost_per_hour),
csvCell(r.reason),
"", // operator_note — empty by default
].join(","),
);
}
return lines.join("\n") + "\n";
}