From 5636730f5ecc7d895ab86f04bd19829e823b87d0 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 13:06:17 +0200 Subject: [PATCH] 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. --- .../exception-report/[batchId]/route.ts | 122 ++++++++++++++++++ src/lib/plant-hours-exception.test.ts | 59 +++++++++ src/lib/plant-hours-exception.ts | 77 +++++++++++ 3 files changed, 258 insertions(+) create mode 100644 src/app/api/plant-hours/exception-report/[batchId]/route.ts create mode 100644 src/lib/plant-hours-exception.test.ts create mode 100644 src/lib/plant-hours-exception.ts diff --git a/src/app/api/plant-hours/exception-report/[batchId]/route.ts b/src/app/api/plant-hours/exception-report/[batchId]/route.ts new file mode 100644 index 0000000..a11ed5c --- /dev/null +++ b/src/app/api/plant-hours/exception-report/[batchId]/route.ts @@ -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(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 }, + ); + } +} \ No newline at end of file diff --git a/src/lib/plant-hours-exception.test.ts b/src/lib/plant-hours-exception.test.ts new file mode 100644 index 0000000..5053565 --- /dev/null +++ b/src/lib/plant-hours-exception.test.ts @@ -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"'); + }); +}); \ No newline at end of file diff --git a/src/lib/plant-hours-exception.ts b/src/lib/plant-hours-exception.ts new file mode 100644 index 0000000..a13e28e --- /dev/null +++ b/src/lib/plant-hours-exception.ts @@ -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"; +} \ No newline at end of file