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.
This commit is contained in:
parent
08ed89d9c8
commit
78fa8da05c
4 changed files with 553 additions and 22 deletions
304
src/app/api/delivery-notes/upload/route.test.ts
Normal file
304
src/app/api/delivery-notes/upload/route.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mirror of src/app/api/purchase-orders/upload/route.test.ts, adapted
|
||||||
|
// for the delivery-notes path. Same pbAdmin mock surface (getFullList
|
||||||
|
// for the dedup query, create for the new DN + batch rows, plus getOne
|
||||||
|
// for the site lookup).
|
||||||
|
const getFullListMock = vi.fn();
|
||||||
|
const createMock = vi.fn();
|
||||||
|
const getOneMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/pb", () => ({
|
||||||
|
pbAdmin: vi.fn(async () => ({
|
||||||
|
collection: (name: string) => {
|
||||||
|
if (name === "data_import_batches") {
|
||||||
|
return { create: createMock, getFullList: getFullListMock };
|
||||||
|
}
|
||||||
|
if (name === "delivery_notes") {
|
||||||
|
return { create: createMock };
|
||||||
|
}
|
||||||
|
if (name === "sites") {
|
||||||
|
return { getOne: getOneMock };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected collection in test: ${name}`);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
function makeRequest(form: FormData): Request {
|
||||||
|
return new Request("http://x/api/delivery-notes/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function slip(
|
||||||
|
bytes = 128,
|
||||||
|
type = "image/jpeg",
|
||||||
|
name = "dn-5678.jpg",
|
||||||
|
): File {
|
||||||
|
return new File([new Uint8Array(bytes)], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEDUP_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
function daysAgoIso(days: number): string {
|
||||||
|
return new Date(Date.now() - days * 86400_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeValidForm(file?: File, site = "siteabc"): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file ?? slip());
|
||||||
|
form.append("site", site);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/delivery-notes/upload", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createMock.mockReset();
|
||||||
|
getFullListMock.mockReset();
|
||||||
|
getOneMock.mockReset();
|
||||||
|
// Default: dedup misses, site lookup returns project_code.
|
||||||
|
getFullListMock.mockResolvedValue([]);
|
||||||
|
getOneMock.mockResolvedValue({ project_code: "P-001" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the body is not multipart form data", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
new Request("http://x/api/delivery-notes/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nope: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "invalid_form_data" });
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
expect(getFullListMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when no file field is present", async () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("site", "siteabc");
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "missing_file" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an empty file", async () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", slip(0));
|
||||||
|
form.append("site", "siteabc");
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "empty_file" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the file exceeds the size cap (20 MB)", async () => {
|
||||||
|
// Build a real oversized file so we don't rely on the request body
|
||||||
|
// actually being 21 MB on the wire — the size check happens before
|
||||||
|
// any hashing, and we don't want this test to allocate that much.
|
||||||
|
const big = slip(128);
|
||||||
|
Object.defineProperty(big, "size", { value: 21 * 1024 * 1024 });
|
||||||
|
const form = makeValidForm(big);
|
||||||
|
const res = await POST({
|
||||||
|
formData: async () => form,
|
||||||
|
} as unknown as Request);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("file_too_large");
|
||||||
|
expect(body.max_bytes).toBe(20 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an unsupported MIME type", async () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"file",
|
||||||
|
new File([new Uint8Array(16)], "dn.docx", {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.append("site", "siteabc");
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toBe("unsupported_type");
|
||||||
|
expect(body.got).toContain("wordprocessingml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when site is missing", async () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", slip());
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "missing_site" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the site lookup fails", async () => {
|
||||||
|
getOneMock.mockRejectedValueOnce(new Error("not found"));
|
||||||
|
const res = await POST(makeRequest(makeValidForm()));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ error: "Site not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a DN + batch row, returns 201 with the new id", async () => {
|
||||||
|
createMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "dnnew1234567890",
|
||||||
|
created: "2026-09-07T10:00:00Z",
|
||||||
|
}) // delivery_notes.create
|
||||||
|
.mockResolvedValueOnce({ id: "batchabcdefghijkl" }); // batches.create
|
||||||
|
|
||||||
|
const form = makeValidForm();
|
||||||
|
form.append("delivery_note_number", "DN-5678");
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(await res.json()).toEqual({
|
||||||
|
id: "dnnew1234567890",
|
||||||
|
deliveryNoteNumber: "DN-5678",
|
||||||
|
siteId: "siteabc",
|
||||||
|
projectCode: "P-001",
|
||||||
|
processingStatus: "pending",
|
||||||
|
created: "2026-09-07T10:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// First create: delivery_notes
|
||||||
|
const dnPayload = createMock.mock.calls[0][0];
|
||||||
|
expect(dnPayload.delivery_note_number).toBe("DN-5678");
|
||||||
|
expect(dnPayload.site).toBe("siteabc");
|
||||||
|
expect(dnPayload.purchase_order).toBeNull();
|
||||||
|
expect(dnPayload.processing_status).toBe("pending");
|
||||||
|
expect(dnPayload.is_verified).toBe(false);
|
||||||
|
expect(dnPayload.received_by).toBe("web-upload");
|
||||||
|
expect(dnPayload.slip_image).toBeInstanceOf(File);
|
||||||
|
|
||||||
|
// Second create: data_import_batches with file_hash
|
||||||
|
const batchPayload = createMock.mock.calls[1][0];
|
||||||
|
expect(batchPayload.batch_type).toBe("delivery_notes");
|
||||||
|
expect(batchPayload.status).toBe("pending");
|
||||||
|
expect(batchPayload.attempts).toBe(0);
|
||||||
|
expect(batchPayload.uploaded_by).toBe("web-upload");
|
||||||
|
expect(batchPayload.processed_record_id).toBe("dnnew1234567890");
|
||||||
|
expect(batchPayload.file_hash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the same hash for byte-identical files (so re-uploads dedup)", async () => {
|
||||||
|
createMock.mockResolvedValue({ id: "dnidxxxxxxxxxxxx" });
|
||||||
|
const form1 = makeValidForm(slip(64));
|
||||||
|
const form2 = makeValidForm(slip(64));
|
||||||
|
|
||||||
|
const r1 = await POST(makeRequest(form1));
|
||||||
|
expect(r1.status).toBe(201);
|
||||||
|
const r2 = await POST(makeRequest(form2));
|
||||||
|
// Second upload: dedup still misses (mocked getFullList returns [])
|
||||||
|
// so a second set of rows is created — but with the SAME file_hash.
|
||||||
|
expect(r2.status).toBe(201);
|
||||||
|
|
||||||
|
const hash1 = createMock.mock.calls[1][0].file_hash;
|
||||||
|
const hash2 = createMock.mock.calls[3][0].file_hash;
|
||||||
|
expect(hash1).toBe(hash2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with duplicate=true when a recent processed batch has the same hash", async () => {
|
||||||
|
getFullListMock.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: "priorbatch1",
|
||||||
|
processed_record_id: "priordn1",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const form = makeValidForm(slip());
|
||||||
|
const res = await POST(makeRequest(form));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.duplicate).toBe(true);
|
||||||
|
expect(body.status).toBe("duplicate");
|
||||||
|
expect(body.existing_batch_id).toBe("priorbatch1");
|
||||||
|
expect(body.existing_dn_id).toBe("priordn1");
|
||||||
|
expect(body.filename).toBe("dn-5678.jpg");
|
||||||
|
// No new DN or batch row created on a dedup hit.
|
||||||
|
expect(createMock).not.toHaveBeenCalled();
|
||||||
|
expect(getOneMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify the dedup query used the hash + status=processed + date cutoff.
|
||||||
|
const filter = getFullListMock.mock.calls[0][0].filter;
|
||||||
|
expect(filter).toContain('status = "processed"');
|
||||||
|
expect(filter).toMatch(/file_hash = "[a-f0-9]{64}"/);
|
||||||
|
expect(filter).toContain("date_uploaded >=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match a batch that is still pending (only 'processed' counts)", async () => {
|
||||||
|
// If only pending batches match, dedup misses and a normal create runs.
|
||||||
|
getFullListMock.mockResolvedValueOnce([]); // empty as if no processed match
|
||||||
|
createMock.mockResolvedValueOnce({ id: "dnnew1" });
|
||||||
|
createMock.mockResolvedValueOnce({ id: "batch1" });
|
||||||
|
|
||||||
|
const res = await POST(makeRequest(makeValidForm()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const filter = getFullListMock.mock.calls[0][0].filter;
|
||||||
|
expect(filter).toContain('status = "processed"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the 30-day cutoff in the dedup query", async () => {
|
||||||
|
// Don't actually return a match — we only care about the filter shape.
|
||||||
|
getFullListMock.mockResolvedValueOnce([]);
|
||||||
|
createMock.mockResolvedValueOnce({ id: "dnx" });
|
||||||
|
createMock.mockResolvedValueOnce({ id: "batchx" });
|
||||||
|
|
||||||
|
await POST(makeRequest(makeValidForm()));
|
||||||
|
|
||||||
|
const filter = getFullListMock.mock.calls[0][0].filter;
|
||||||
|
const cutoffMatch = filter.match(/date_uploaded >= "([^"]+)"/);
|
||||||
|
expect(cutoffMatch).not.toBeNull();
|
||||||
|
const cutoff = new Date(cutoffMatch![1]).getTime();
|
||||||
|
const expected = Date.now() - DEDUP_WINDOW_DAYS * 86400_000;
|
||||||
|
expect(Math.abs(cutoff - expected)).toBeLessThan(5_000);
|
||||||
|
// Sanity-check we're not accidentally using daysAgoIso(0).
|
||||||
|
expect(daysAgoIso(0)).not.toBe(cutoffMatch![1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls through to create when the dedup query throws", async () => {
|
||||||
|
getFullListMock.mockRejectedValueOnce(new Error("pb dedup down"));
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
createMock.mockResolvedValueOnce({ id: "dnz" });
|
||||||
|
createMock.mockResolvedValueOnce({ id: "batchz" });
|
||||||
|
|
||||||
|
const res = await POST(makeRequest(makeValidForm()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(createMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still creates a DN when the batch-tracking row fails (OCR can still pick it up)", async () => {
|
||||||
|
getOneMock.mockResolvedValue({ project_code: "P-001" });
|
||||||
|
createMock.mockResolvedValueOnce({ id: "dnonlyone1" }); // DN succeeds
|
||||||
|
createMock.mockRejectedValueOnce(new Error("batches broken")); // batches fails
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const res = await POST(makeRequest(makeValidForm()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(await res.json()).toMatchObject({ id: "dnonlyone1" });
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when the DN row cannot be created", async () => {
|
||||||
|
createMock.mockRejectedValueOnce(new Error("pb down"));
|
||||||
|
const res = await POST(makeRequest(makeValidForm()));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
const body = await res.json();
|
||||||
|
// The route surfaces the underlying PB error so the operator can see
|
||||||
|
// why the create failed; we just assert it's the 500 payload.
|
||||||
|
expect(typeof body.error).toBe("string");
|
||||||
|
expect(body.error.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,23 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { pbAdmin } from "@/lib/pb";
|
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
|
* POST /api/delivery-notes/upload
|
||||||
*
|
*
|
||||||
|
|
@ -19,22 +36,110 @@ import { pbAdmin } from "@/lib/pb";
|
||||||
* - processing_status = "pending" so the n8n OCR pipeline picks it up
|
* - processing_status = "pending" so the n8n OCR pipeline picks it up
|
||||||
* - purchase_order = null (filled in by OCR webhook on match)
|
* - purchase_order = null (filled in by OCR webhook on match)
|
||||||
* - date_received = upload time
|
* - 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) {
|
export async function POST(request: Request) {
|
||||||
const form = await request.formData();
|
const start = Date.now();
|
||||||
const file = form.get("file");
|
|
||||||
const siteId = (form.get("site") as string | null)?.trim();
|
|
||||||
const deliveryNoteNumber = (form.get("delivery_note_number") as string | null)?.trim();
|
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
let form: FormData;
|
||||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
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) {
|
if (!siteId) {
|
||||||
return NextResponse.json({ error: "Missing site" }, { status: 400 });
|
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();
|
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
|
// Resolve site → project_code for the DN's project_code field (used by
|
||||||
// older reports that filter by text rather than the relation).
|
// older reports that filter by text rather than the relation).
|
||||||
let resolvedProject = "";
|
let resolvedProject = "";
|
||||||
|
|
@ -44,10 +149,7 @@ export async function POST(request: Request) {
|
||||||
});
|
});
|
||||||
resolvedProject = site.project_code ?? "";
|
resolvedProject = site.project_code ?? "";
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Site not found" }, { status: 400 });
|
||||||
{ error: "Site not found" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -68,7 +170,8 @@ export async function POST(request: Request) {
|
||||||
// the dashboard's "imports" view) can track this upload through
|
// the dashboard's "imports" view) can track this upload through
|
||||||
// pending → processed/failed/dead. The row is linked back to the
|
// pending → processed/failed/dead. The row is linked back to the
|
||||||
// delivery_note via `processed_record_id` so the OCR result route
|
// delivery_note via `processed_record_id` so the OCR result route
|
||||||
// can find it without a relation field on delivery_notes.
|
// 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 {
|
try {
|
||||||
await pb.collection("data_import_batches").create({
|
await pb.collection("data_import_batches").create({
|
||||||
batch_type: "delivery_notes",
|
batch_type: "delivery_notes",
|
||||||
|
|
@ -78,6 +181,7 @@ export async function POST(request: Request) {
|
||||||
date_uploaded: new Date().toISOString(),
|
date_uploaded: new Date().toISOString(),
|
||||||
uploaded_by: "web-upload",
|
uploaded_by: "web-upload",
|
||||||
original_file: file,
|
original_file: file,
|
||||||
|
file_hash,
|
||||||
processed_record_id: dn.id,
|
processed_record_id: dn.id,
|
||||||
});
|
});
|
||||||
} catch (batchErr) {
|
} catch (batchErr) {
|
||||||
|
|
@ -91,14 +195,17 @@ export async function POST(request: Request) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
id: dn.id,
|
{
|
||||||
deliveryNoteNumber: deliveryNoteNumber || "",
|
id: dn.id,
|
||||||
siteId,
|
deliveryNoteNumber: deliveryNoteNumber || "",
|
||||||
projectCode: resolvedProject,
|
siteId,
|
||||||
processingStatus: "pending",
|
projectCode: resolvedProject,
|
||||||
created: dn.created,
|
processingStatus: "pending",
|
||||||
});
|
created: dn.created,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FileSpreadsheet, Loader2, Upload, X } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Loader2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,7 +29,7 @@ import {
|
||||||
import { useFileDropZone } from "@/lib/use-file-drop-zone";
|
import { useFileDropZone } from "@/lib/use-file-drop-zone";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Phase = "idle" | "uploading" | "done" | "error";
|
type Phase = "idle" | "uploading" | "done" | "duplicate" | "error";
|
||||||
|
|
||||||
interface UploadResult {
|
interface UploadResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -32,6 +40,19 @@ interface UploadResult {
|
||||||
created: string;
|
created: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returned by /api/delivery-notes/upload when SHA-256 dedup found a
|
||||||
|
* matching file within the configured window. We don't create a new
|
||||||
|
* DN — the operator should switch to the existing one. Shape mirrors
|
||||||
|
* the PO upload `duplicate` payload, just with `existing_dn_id`
|
||||||
|
* instead of `existing_po_id`.
|
||||||
|
*/
|
||||||
|
interface DuplicateInfo {
|
||||||
|
existingDnId: string | null;
|
||||||
|
existingBatchId: string | null;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteOption {
|
export interface SiteOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -57,6 +78,7 @@ export function UploadDeliveryNoteButton({
|
||||||
const [file, setFile] = React.useState<File | null>(null);
|
const [file, setFile] = React.useState<File | null>(null);
|
||||||
const [phase, setPhase] = React.useState<Phase>("idle");
|
const [phase, setPhase] = React.useState<Phase>("idle");
|
||||||
const [result, setResult] = React.useState<UploadResult | null>(null);
|
const [result, setResult] = React.useState<UploadResult | null>(null);
|
||||||
|
const [duplicate, setDuplicate] = React.useState<DuplicateInfo | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
|
@ -66,6 +88,7 @@ export function UploadDeliveryNoteButton({
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setPhase("idle");
|
setPhase("idle");
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setDuplicate(null);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
if (inputRef.current) inputRef.current.value = "";
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -79,6 +102,7 @@ export function UploadDeliveryNoteButton({
|
||||||
if (!file || !siteId) return;
|
if (!file || !siteId) return;
|
||||||
setPhase("uploading");
|
setPhase("uploading");
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
setDuplicate(null);
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
|
|
@ -89,6 +113,25 @@ export function UploadDeliveryNoteButton({
|
||||||
body: form,
|
body: form,
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
// 200 + duplicate:true = server caught a byte-identical recent
|
||||||
|
// upload. No new DN was created — route the operator to the
|
||||||
|
// existing one instead.
|
||||||
|
if (res.status === 200 && data && data.duplicate === true) {
|
||||||
|
setDuplicate({
|
||||||
|
existingDnId:
|
||||||
|
typeof data.existing_dn_id === "string"
|
||||||
|
? data.existing_dn_id
|
||||||
|
: null,
|
||||||
|
existingBatchId:
|
||||||
|
typeof data.existing_batch_id === "string"
|
||||||
|
? data.existing_batch_id
|
||||||
|
: null,
|
||||||
|
filename:
|
||||||
|
typeof data.filename === "string" ? data.filename : file.name,
|
||||||
|
});
|
||||||
|
setPhase("duplicate");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed");
|
setErrorMsg(typeof data.error === "string" ? data.error : "Upload failed");
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
|
|
@ -154,6 +197,8 @@ export function UploadDeliveryNoteButton({
|
||||||
|
|
||||||
{phase === "done" && result ? (
|
{phase === "done" && result ? (
|
||||||
<DonePanel result={result} onClose={close} />
|
<DonePanel result={result} onClose={close} />
|
||||||
|
) : phase === "duplicate" && duplicate ? (
|
||||||
|
<DuplicatePanel duplicate={duplicate} onClose={close} />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
@ -333,3 +378,69 @@ function DonePanel({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the upload route returns 200 + `duplicate: true`. The
|
||||||
|
* server hashed the bytes and matched a recent processed batch — we did
|
||||||
|
* NOT create a new DN, so the operator's options are: (a) leave and
|
||||||
|
* look up the existing row themselves, (b) re-upload anyway by first
|
||||||
|
* editing the existing DN (we don't expose that flow here on purpose),
|
||||||
|
* or (c) open the existing one via the purchasing page.
|
||||||
|
*/
|
||||||
|
function DuplicatePanel({
|
||||||
|
duplicate,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
duplicate: DuplicateInfo;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { existingDnId, existingBatchId, filename } = duplicate;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium text-amber-700 dark:text-amber-300">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
This slip has already been uploaded
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
The file <span className="font-mono text-foreground">{filename}</span>{" "}
|
||||||
|
matched a delivery note that's already in the system. No new
|
||||||
|
record was created, and no OCR was spent on this attempt — we
|
||||||
|
stopped at the hash check to avoid a duplicate.
|
||||||
|
</p>
|
||||||
|
{existingDnId && (
|
||||||
|
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Existing DN:</span>
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{existingDnId}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{existingBatchId && (
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Batch row:</span>
|
||||||
|
<span className="font-mono text-foreground">{existingBatchId}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-2 text-xs text-muted-foreground">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<p>
|
||||||
|
If the original upload had OCR problems (no PO auto-matched,
|
||||||
|
missing DN number, etc.), open the row in the awaiting-PO
|
||||||
|
inbox to fix it rather than re-uploading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<a
|
||||||
|
href="/dashboard/purchasing"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-[#F2B165] hover:underline"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
Open purchasing page
|
||||||
|
</a>
|
||||||
|
<Button onClick={onClose}>Close</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,15 @@ export interface DataImportBatchRecord extends RecordModel {
|
||||||
next_retry_at?: string;
|
next_retry_at?: string;
|
||||||
processed_at?: string;
|
processed_at?: string;
|
||||||
processed_record_id?: string;
|
processed_record_id?: string;
|
||||||
|
/**
|
||||||
|
* Lowercase hex SHA-256 of the uploaded file's bytes. Populated by the
|
||||||
|
* upload routes so byte-identical re-uploads can dedup within the
|
||||||
|
* configured window (currently 30 days). Indexed as
|
||||||
|
* `idx_batches_file_hash` on the `data_import_batches` collection for
|
||||||
|
* the dedup query — see `src/app/api/purchase-orders/upload/route.ts`
|
||||||
|
* and `src/app/api/delivery-notes/upload/route.ts`.
|
||||||
|
*/
|
||||||
|
file_hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseOrderRecord extends RecordModel {
|
export interface PurchaseOrderRecord extends RecordModel {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue