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.
217 lines
7.3 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|