diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 5a45f20..a08bab9 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -560,7 +560,12 @@ const tools = [ "Each item creates an order_item row with item_status=\"pending\" and drv_status=\"unassigned\". " + "If the per-row secondary fields (sku/unit/supplier_id/notes) fail to patch, the row stays " + "created (with defaults) and the failure is reported in items_failed — never aborts the " + - "whole order.", + "whole order.\n\n" + + "Optional `order_number`: pass a customer-supplied identifier (e.g. the PO/reference printed " + + "on the inbound PO PDF) to use it verbatim as `orders.order_number`. Empty / whitespace " + + "values fall back to auto-generated `ORD-YYYY-MM-NNN` — same as omitting the field. On " + + "collision the call surfaces an MCP error envelope with code/message `order_number_collision` " + + "and a `data` block carrying the colliding order id and attempted number.", inputSchema: { type: "object", required: ["customer_id", "company_id", "items"], @@ -571,6 +576,7 @@ const tools = [ contact_name:{ type: "string", description: "Operator-readable contact name (free-text)." }, description: { type: "string", description: "Short description of the order (free-text)." }, rep: { type: "string", description: "Sales rep name (free-text)." }, + order_number:{ type: "string", description: "Optional custom order_number (e.g. PO/ref printed on the inbound attachment). Empty/whitespace falls back to auto-generation." }, items: { type: "array", minItems: 1, @@ -3598,20 +3604,46 @@ export async function POST(req: NextRequest) { supplier_id: typeof it.supplier_id === "string" ? it.supplier_id : undefined, notes: typeof it.notes === "string" ? it.notes : undefined, })); - const result = await createOrder({ - customer_id: customerId, - company_id: companyId, - notes: typeof args.notes === "string" ? args.notes : undefined, - contact_name: typeof args.contact_name === "string" ? args.contact_name : undefined, - description: typeof args.description === "string" ? args.description : undefined, - rep: typeof args.rep === "string" ? args.rep : undefined, - items, - }); - return NextResponse.json({ - jsonrpc: "2.0", - id: body.id ?? null, - result: { content: [{ type: "text", text: JSON.stringify(result) }] }, - }); + try { + const result = await createOrder({ + customer_id: customerId, + company_id: companyId, + notes: typeof args.notes === "string" ? args.notes : undefined, + contact_name: typeof args.contact_name === "string" ? args.contact_name : undefined, + // Empty / whitespace order_number is treated as "not provided" + // so the service layer falls back to auto-generation. + order_number: typeof args.order_number === "string" ? args.order_number : undefined, + description: typeof args.description === "string" ? args.description : undefined, + rep: typeof args.rep === "string" ? args.rep : undefined, + items, + }); + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { content: [{ type: "text", text: JSON.stringify(result) }] }, + }); + } catch (err) { + // Surface custom-order_number collisions as a structured MCP + // error envelope — same shape the review-save path returns, + // so the n8n intake workflow (and any agent caller) can + // branch on `error.message === "order_number_collision"` and + // read `error.data.{collidingOrderId, attemptedNumber}`. + if (err instanceof OrderNumberCollisionError) { + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + error: { + code: -32000, + message: "order_number_collision", + data: { + collidingOrderId: err.collidingOrderId, + attemptedNumber: err.attemptedNumber, + }, + }, + }); + } + throw err; + } } if (toolName === "orders.split") { diff --git a/src/app/api/v1/orders/create/route.ts b/src/app/api/v1/orders/create/route.ts index 7ae70d1..969a5a5 100644 --- a/src/app/api/v1/orders/create/route.ts +++ b/src/app/api/v1/orders/create/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { assertAgentAuth } from "@/lib/server/agent-auth"; import { createOrder, + OrderNumberCollisionError, type CreateOrderInput, type CreateOrderItemDraft, } from "@/lib/server/orders-service"; @@ -21,6 +22,7 @@ import { * contact_name?: string, * description?: string, * email_message_id?: string, // dedup key — returns existing order if one already exists + * order_number?: string, // optional custom order number; empty/whitespace falls back to auto-gen * rep?: string, * items: [ * { @@ -40,8 +42,10 @@ import { * * Returns: * 201 — { order, order_items_collection, items_created, items_failed } + * 200 — { ...same shape..., duplicate: true } dedup hit (idempotent) * 400 — { error: } validation failure (missing fields, empty items) * 404 — { error: } customer_id / company_id doesn't resolve + * 409 — { error: "order_number_collision", collidingOrderId, attemptedNumber } * 500 — { error: } PB write failure * * `runtime = "nodejs"` because PocketBase admin auth requires Node. @@ -88,6 +92,11 @@ export async function POST(req: NextRequest) { notes: typeof body?.notes === "string" ? body.notes : undefined, contact_name: typeof body?.contact_name === "string" ? body.contact_name : undefined, email_message_id: typeof body?.email_message_id === "string" ? body.email_message_id : undefined, + // Empty / whitespace order_number is treated as "not provided" + // so the service layer falls back to auto-generation — same shape + // as the workflow sending `order_number: ""` when the AI didn't + // find a PO number on the inbound attachment. + order_number: typeof body?.order_number === "string" ? body.order_number : undefined, description: typeof body?.description === "string" ? body.description : undefined, rep: typeof body?.rep === "string" ? body.rep : undefined, items, @@ -96,6 +105,21 @@ export async function POST(req: NextRequest) { // 200 for dedup (idempotent — same input, same output), 201 for new return NextResponse.json(result, { status: result.duplicate ? 200 : 201 }); } catch (error) { + // Structured 409 for custom order_number collisions — mirrors the + // shape /api/v1/orders/[id]/review-save returns so callers (the + // n8n intake workflow, agent runs, UI modals) can branch on the + // same error code regardless of which endpoint raised it. + if (error instanceof OrderNumberCollisionError) { + return NextResponse.json( + { + error: "order_number_collision", + collidingOrderId: error.collidingOrderId, + attemptedNumber: error.attemptedNumber, + }, + { status: 409 }, + ); + } + const message = error instanceof Error ? error.message : "Failed to create order"; const status = (error as Error & { status?: number }).status || 500; // 404 from getOne propagates with status === 404 if PB throws it. diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 88dc703..72ef1ed 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1910,6 +1910,21 @@ export interface CreateOrderInput { * dedup check entirely. */ email_message_id?: string; + /** + * Optional customer-supplied order number (e.g. the PO/reference + * printed on the inbound PO PDF that the n8n intake pipeline + * extracts from the attachment). When provided, the value is + * used verbatim as `orders.order_number` instead of the auto- + * generated `ORD-YYYY-MM-NNN`. Empty / whitespace-only values + * are ignored — `createOrder` falls back to auto-generation, + * same as if the field were omitted. + * + * Uniqueness is enforced against the existing `orders` collection + * before write (and again via DB-level unique index on race); + * collisions throw `OrderNumberCollisionError` so the caller can + * surface a 409 with the colliding order id. + */ + order_number?: string; } export interface CreateOrderResult { @@ -2007,7 +2022,33 @@ export async function createOrder(input: CreateOrderInput): Promise(1, 1, { + filter: `order_number = "${escapedNumber}"`, + }); + if (conflict.items.length > 0) { + throw new OrderNumberCollisionError( + conflict.items[0].id, + customOrderNumber, + ); + } + orderNumber = customOrderNumber; + } else { + orderNumber = await generateOrderNumberServer(pb); + } + let order: Order; try { order = (await pb.collection(ORDER_COLLECTION).create({ @@ -2028,13 +2069,15 @@ export async function createOrder(input: CreateOrderInput): Promise(1, 1, { + filter: `order_number = "${escapedNumber}"`, + }); + if (conflict.items[0]) { + throw new OrderNumberCollisionError( + conflict.items[0].id, + customOrderNumber, + ); + } + } catch (collisionErr) { + if (collisionErr instanceof OrderNumberCollisionError) throw collisionErr; + // Fall through to the original error if the re-query itself blew up. + } + } + throw err; }