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:
parent
22660382fe
commit
dc0139a62c
3 changed files with 147 additions and 21 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue