MEGAINFRA-WEBAPP/src/app/api/delivery-notes/upload/route.ts
DEls 78fa8da05c feat(delivery-notes): dedup by SHA-256 at upload time
Mirrors the existing /api/purchase-orders/upload pattern onto the
delivery-note path so byte-identical re-uploads short-circuit with
`200 + { duplicate: true, existing_dn_id, ... }` instead of
creating a second delivery_notes row.

Concretely:
- Hash the bytes with SHA-256 before any PB round-trip.
- Query `data_import_batches` for any processed batch with the same
  hash inside a 30-day window (the same window the PO route uses; the
  underlying `idx_batches_file_hash` index already covers it).
- On hit, return the existing DN + batch ids and skip creation. The
  n8n OCR pipeline is never invoked for a duplicate slip, so a
  double-clicking operator costs us nothing.
- On miss, stamp `file_hash` on the new batch row so subsequent
  re-uploads can dedup against it.
- Wire the response through `UploadDeliveryNoteButton` as a new
  'duplicate' panel showing the existing DN id plus a quick link to
  /dashboard/purchasing where the awaiting-PO inbox lives.

pb-types: adds file_hash?: string to DataImportBatchRecord so the
column the PB schema already has is reflected in the TS shape.
2026-09-07 14:49:33 +02:00

217 lines
7.3 KiB
TypeScript

import { NextResponse } from "next/server";
import { createHash } from "node:crypto";
import { pbAdmin } from "@/lib/pb";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ALLOWED_MIMES = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
] as const;
const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB
// Dedup window for byte-identical re-uploads. Matches the PO upload
// route's window. Anything older than this is ignored — the OCR
// pipeline's `delivery_note` relation is the source of truth for those.
const DEDUP_WINDOW_DAYS = 30;
/**
* POST /api/delivery-notes/upload
*
* Manual delivery-note upload (companion to the n8n OCR pipeline).
* Accepts multipart/form-data with:
* site - site id (required) — used to stamp the DN and
* resolve project_code when the PO match fails
* delivery_note_number - string (optional; AI will fill it from the slip)
* file - the slip photo or PDF (required)
*
* The PO is NOT supplied at upload time — the OCR pipeline will look it up
* from the slip via /api/delivery-notes/[id]/ocr-result, or it stays null
* for manual review on the purchasing page.
*
* Creates a DELIVERY_NOTES record with:
* - processing_status = "pending" so the n8n OCR pipeline picks it up
* - purchase_order = null (filled in by OCR webhook on match)
* - date_received = upload time
*
* Hash dedup: before creating rows, we SHA-256 the bytes and look for a
* recent (last 30 days) `data_import_batches` row with the same hash that
* already reached `status=processed`. If we find one, we short-circuit
* with `{ duplicate: true, existing_batch_id, existing_dn_id, ... }` —
* no new DN, no new batch row, no wasted OCR tokens. Mirrors the
* behaviour of /api/purchase-orders/upload so the operator gets the
* same experience on both upload paths.
*/
export async function POST(request: Request) {
const start = Date.now();
let form: FormData;
try {
form = await request.formData();
} catch {
return NextResponse.json({ error: "invalid_form_data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing_file" }, { status: 400 });
}
if (file.size === 0) {
return NextResponse.json({ error: "empty_file" }, { status: 400 });
}
if (file.size > MAX_FILE_BYTES) {
return NextResponse.json(
{ error: "file_too_large", max_bytes: MAX_FILE_BYTES },
{ status: 400 },
);
}
if (!ALLOWED_MIMES.includes(file.type as (typeof ALLOWED_MIMES)[number])) {
return NextResponse.json(
{
error: "unsupported_type",
got: file.type,
allowed: [...ALLOWED_MIMES],
},
{ status: 400 },
);
}
const siteId = (form.get("site") as string | null)?.trim();
const deliveryNoteNumber = (
form.get("delivery_note_number") as string | null
)?.trim();
if (!siteId) {
return NextResponse.json({ error: "missing_site" }, { status: 400 });
}
// Hash the file bytes. Cheap enough to do before the PB round-trip
// (20 MB worst case, but in practice slips are a few hundred KB).
let file_hash: string;
try {
const bytes = Buffer.from(await file.arrayBuffer());
file_hash = createHash("sha256").update(bytes).digest("hex");
} catch (err) {
console.error("[dn-upload] hash_failed", { err: String(err) });
return NextResponse.json({ error: "hash_failed" }, { status: 500 });
}
const pb = await pbAdmin();
// Hash dedup: look for a recent processed batch with the same hash.
// Uses the idx_batches_file_hash index on (file_hash, status).
let existing: Array<{ id: string; processed_record_id?: string }> = [];
try {
const cutoff = new Date(
Date.now() - DEDUP_WINDOW_DAYS * 86400_000,
).toISOString();
existing = await pb.collection("data_import_batches").getFullList({
filter: `file_hash = "${file_hash}" && status = "processed" && date_uploaded >= "${cutoff}"`,
sort: "-date_uploaded",
fields: "id,processed_record_id",
});
} catch (err) {
// Dedup query failing is non-fatal — log and proceed to create the
// DN. Worst case is we miss the dedup and the operator will spot
// it manually on the awaiting-PO assignment inbox.
console.warn("[dn-upload] dedup_query_failed", { err: String(err) });
}
if (existing.length > 0) {
const prior = existing[0];
console.log("[dn-upload] duplicate_by_hash", {
file_hash,
existing_batch_id: prior.id,
existing_dn_id: prior.processed_record_id ?? null,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{
duplicate: true,
status: "duplicate",
existing_batch_id: prior.id,
existing_dn_id: prior.processed_record_id ?? null,
filename: file.name,
},
{ status: 200 },
);
}
// Resolve site → project_code for the DN's project_code field (used by
// older reports that filter by text rather than the relation).
let resolvedProject = "";
try {
const site = await pb.collection("sites").getOne(siteId, {
fields: "project_code",
});
resolvedProject = site.project_code ?? "";
} catch {
return NextResponse.json({ error: "Site not found" }, { status: 400 });
}
try {
const dn = await pb.collection("delivery_notes").create({
delivery_note_number: deliveryNoteNumber || "",
project_code: resolvedProject,
site: siteId,
purchase_order: null,
date_received: new Date().toISOString(),
received_by: "web-upload",
slip_image: file,
raw_ocr_data: { source: "manual-upload", fileName: file.name },
is_verified: false,
processing_status: "pending",
});
// Create a data_import_batches row so the n8n OCR pipeline (and
// the dashboard's "imports" view) can track this upload through
// pending → processed/failed/dead. The row is linked back to the
// delivery_note via `processed_record_id` so the OCR result route
// can find it without a relation field on delivery_notes. We also
// stamp `file_hash` so a future re-upload can dedup against it.
try {
await pb.collection("data_import_batches").create({
batch_type: "delivery_notes",
content_type: file.type || "application/octet-stream",
status: "pending",
attempts: 0,
date_uploaded: new Date().toISOString(),
uploaded_by: "web-upload",
original_file: file,
file_hash,
processed_record_id: dn.id,
});
} catch (batchErr) {
// Don't fail the upload if the batch row can't be created — the
// DN is already persisted and the OCR pipeline can still pick
// it up via processing_status='pending'. The batch row is just
// a tracking convenience.
console.warn(
"[dn-upload] failed to create data_import_batches row:",
batchErr,
);
}
return NextResponse.json(
{
id: dn.id,
deliveryNoteNumber: deliveryNoteNumber || "",
siteId,
projectCode: resolvedProject,
processingStatus: "pending",
created: dn.created,
},
{ status: 201 },
);
} catch (err) {
return NextResponse.json(
{
error: err instanceof Error ? err.message : "Failed to create delivery note",
},
{ status: 500 },
);
}
}