feat(api): add support for optional custom order_number in order creation

- Introduced `order_number` field in the order creation input schema, allowing users to provide a custom identifier (e.g., PO/reference).
- Implemented logic to handle empty or whitespace values, defaulting to auto-generated order numbers.
- Added error handling for order_number collisions, returning structured error responses for better client-side handling.
- Updated documentation to reflect the new order_number functionality and its implications.

This enhancement improves order management flexibility and user experience.
This commit is contained in:
Dave 2026-08-28 12:30:38 +02:00
parent 22660382fe
commit dc0139a62c
3 changed files with 147 additions and 21 deletions

View file

@ -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") {

View file

@ -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: <message> } validation failure (missing fields, empty items)
* 404 — { error: <message> } customer_id / company_id doesn't resolve
* 409 — { error: "order_number_collision", collidingOrderId, attemptedNumber }
* 500 — { error: <message> } 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.

View file

@ -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<CreateOrderR
// --- Order row ------------------------------------------------------------
// Mirrors the field set `createOrderFromQuote` writes, minus the
// quote-related fields (no quote on a standalone order).
const orderNumber = await generateOrderNumberServer(pb);
//
// `orderNumber` resolution: caller-supplied custom number wins
// (after trim); empty / whitespace / undefined fall through to the
// auto-generated `ORD-YYYY-MM-NNN`. The custom path runs the same
// collision pre-check `updateOrderReviewFields` uses, so a 409
// surfaces the conflicting order id to the caller. Race recovery
// for the custom path lives in the catch block below.
const customOrderNumber = input.order_number?.trim();
let orderNumber: string;
if (customOrderNumber) {
const escapedNumber = customOrderNumber.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(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<CreateOrderR
email_message_id: input.email_message_id?.trim() || "",
})) as Order;
} catch (err) {
// Race: another concurrent createOrder call (or the n8n workflow
// calling pb.collection('orders').create directly) inserted the
// same email_message_id between our pre-check and this create.
// The DB-level UNIQUE index on email_message_id rejected the
const message = err instanceof Error ? err.message : String(err);
// Race 1 — `email_message_id` collision: another concurrent
// createOrder call (or the n8n workflow calling
// pb.collection('orders').create directly) inserted the same
// email_message_id between our pre-check and this create. The
// DB-level UNIQUE index on email_message_id rejected the
// insert. Return the existing record instead of throwing — the
// caller asked "create or get"; this is the idempotent answer.
const message = err instanceof Error ? err.message : String(err);
if (input.email_message_id && /unique|email_message_id/i.test(message)) {
try {
const existing = await pb
@ -2054,6 +2097,33 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// fall through to the original error.
}
}
// Race 2 — `order_number` collision: a concurrent caller
// committed the same custom order_number between our pre-check
// and this create. The DB-level UNIQUE index on order_number
// rejected the insert. Map to the structured collision error so
// the route can 409 with the colliding order id (PB's native
// 400 string doesn't carry it).
if (customOrderNumber && /UNIQUE constraint failed/i.test(message)) {
try {
const escapedNumber = customOrderNumber.replace(/"/g, '\\"');
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string }>(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;
}