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:
parent
73ad9fdb5a
commit
5636730f5e
3 changed files with 258 additions and 0 deletions
122
src/app/api/plant-hours/exception-report/[batchId]/route.ts
Normal file
122
src/app/api/plant-hours/exception-report/[batchId]/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
59
src/lib/plant-hours-exception.test.ts
Normal file
59
src/lib/plant-hours-exception.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
77
src/lib/plant-hours-exception.ts
Normal file
77
src/lib/plant-hours-exception.ts
Normal 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";
|
||||
}
|
||||
Loading…
Reference in a new issue