Add /api/purchase-orders/ingest route (n8n callback, writes to PB)
This commit is contained in:
parent
2be0c8de8b
commit
a295d9ea18
1 changed files with 177 additions and 0 deletions
177
src/app/api/purchase-orders/ingest/route.ts
Normal file
177
src/app/api/purchase-orders/ingest/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue