diff --git a/src/app/api/purchase-orders/ingest/route.ts b/src/app/api/purchase-orders/ingest/route.ts new file mode 100644 index 0000000..48d2286 --- /dev/null +++ b/src/app/api/purchase-orders/ingest/route.ts @@ -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 }, + ); +}