Add /api/purchase-orders/ingest route (n8n callback, writes to PB)

This commit is contained in:
DEls 2026-08-19 02:46:32 +02:00
parent 2be0c8de8b
commit a295d9ea18

View file

@ -0,0 +1,177 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import {
validatePurchaseOrderPayload,
matchSite,
computeTotalValue,
} from "@/lib/purchase-order-schema";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function POST(request: Request) {
const start = Date.now();
// 1. Auth
const secret = request.headers.get("x-ingest-secret");
if (!secret || secret !== process.env.N8N_INGEST_SECRET) {
console.warn("[po-ingest] unauthorized");
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// 2. Parse JSON
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
// 3. Validate
const v = validatePurchaseOrderPayload(body);
if (!v.ok) {
console.warn("[po-ingest] validation_failed", { details: v.details });
return NextResponse.json(
{ error: v.error, details: v.details },
{ status: 400 },
);
}
const payload = v.payload;
// 4. Decode file
let fileBuffer: Buffer;
try {
fileBuffer = Buffer.from(payload.file.base64, "base64");
} catch (err) {
console.error("[po-ingest] decode_file failed", { err: String(err) });
return NextResponse.json(
{ error: "decode_failed", step: "decode_file" },
{ status: 500 },
);
}
const pb = await pbAdmin();
// 5. Idempotency: check for existing po_number
try {
const existing = await pb.collection("purchase_orders").getFullList({
filter: `purchase_order_number = "${payload.po_number.replace(/"/g, '\\"')}"`,
});
if (existing.length > 0) {
return NextResponse.json(
{ error: "duplicate_po_number", existing_id: existing[0].id },
{ status: 409 },
);
}
} catch (err) {
console.error("[po-ingest] lookup_duplicate failed", { err: String(err) });
return NextResponse.json(
{ error: "lookup_failed", step: "lookup_duplicate" },
{ status: 500 },
);
}
// 6. Match site
const warnings: string[] = [];
let siteId: string | null = null;
try {
const match = await matchSite(pb, payload.project_code);
siteId = match.siteId;
if (match.warning) warnings.push(match.warning);
} catch (err) {
console.error("[po-ingest] lookup_site failed", { err: String(err) });
return NextResponse.json(
{ error: "lookup_failed", step: "lookup_site" },
{ status: 500 },
);
}
// 7. Create PO record (with file + denormalized fields)
let poRecord: { id: string };
try {
const fileForPb = new File(
[new Uint8Array(fileBuffer)],
payload.file.filename,
{ type: payload.file.contentType },
);
poRecord = await pb.collection("purchase_orders").create({
purchase_order_number: payload.po_number,
project_code: payload.project_code,
supplier: payload.supplier,
required_by_date: payload.required_by_date ?? null,
ordered_by: payload.ordered_by ?? "",
items_count: payload.items.length,
total_value: computeTotalValue(payload.items),
purchase_order_file: fileForPb,
raw_ocr_data: {
...payload.raw_ocr,
ingested_at: new Date().toISOString(),
matched_site_id: siteId,
unmatched_project_code: !siteId,
},
is_verified: false,
site: siteId,
});
} catch (err) {
console.error("[po-ingest] create_po failed", {
err: String(err),
po_number: payload.po_number,
});
return NextResponse.json(
{ error: "create_failed", step: "create_po" },
{ status: 500 },
);
}
// 8. Create line items (sequential — PocketBase SDK has no batch insert)
try {
for (const item of payload.items) {
const disc = 1 - (item.disc_percent ?? 0) / 100;
const vat = 1 + (item.vat_percent ?? 0) / 100;
const amount = Math.round(item.qty * item.rate * disc * vat * 100) / 100;
await pb.collection("purchase_order_items").create({
purchase_order: poRecord.id,
material_code: item.material_code ?? "",
cost_code: item.cost_code ?? "",
description: item.description,
qty: item.qty,
qty_outstanding: item.qty,
unit: item.unit ?? "",
rate: item.rate,
disc_percent: item.disc_percent ?? 0,
vat_percent: item.vat_percent ?? 0,
amount,
});
}
} catch (err) {
console.error("[po-ingest] create_items failed", {
err: String(err),
po_id: poRecord.id,
});
return NextResponse.json(
{ error: "create_failed", step: "create_items" },
{ status: 500 },
);
}
console.log("[po-ingest] ok", {
po_number: payload.po_number,
project_code: payload.project_code,
matched_site_id: siteId,
items_count: payload.items.length,
total_value: computeTotalValue(payload.items),
duration_ms: Date.now() - start,
warnings,
});
return NextResponse.json(
{
ok: true,
id: poRecord.id,
po_number: payload.po_number,
warnings: warnings.length > 0 ? warnings : undefined,
},
{ status: 200 },
);
}