PO numbers were "read the month's highest, add one", so POs for several suppliers on one order created together got the same number — 34 numbers by 2026-10-19 (e.g. PO202610008LIF for BarloWorld CAT and John Ratcliffe, created 71 ms apart). - createPurchaseOrderWithNumber (po-number.ts): numbers and creates POs one at a time in the server, retries (up to 5) when the unique index rejects a clash; ignores any po_number passed in. - buyer-tasks / MCP generate_po use it. - The Process page's Send PO and in-stock PO buttons now call POST /api/v1/orders/[id]/purchase-orders (browser session) instead of numbering in the browser; the client generatePONumber is removed from pb-helpers. - npm run pb:migrate:po-number-unique adds a partial unique index on purchase_orders.po_number for POs created from then on; the 34 existing duplicates are left as they are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
|
|
/**
|
|
* Derive a 3-letter company prefix from a company name.
|
|
*
|
|
* Rule: first 3 letters of the first word, uppercase, stripped of
|
|
* non-alphanumerics. "Master Drilling" → "MAS", "NSS Fastners" → "NSS",
|
|
* "Cash Sales" → "CAS". For names shorter than 3 chars, pads with X.
|
|
* Empty/missing falls back to "GEN" so the PO format stays valid.
|
|
*/
|
|
export function companyPrefix(companyName: string | null | undefined): string {
|
|
if (!companyName) return "GEN";
|
|
const cleaned = companyName.replace(/[^a-zA-Z0-9\s]/g, "").trim();
|
|
const firstWord = cleaned.split(/\s+/)[0] ?? "";
|
|
return firstWord.slice(0, 3).toUpperCase().padEnd(3, "X");
|
|
}
|
|
|
|
/**
|
|
* Generate the next PO number for an order.
|
|
*
|
|
* Format: `PO{YYYYMM}{NNN}{XXX}` (no dashes), where:
|
|
* - YYYYMM = year + month, 6 digits
|
|
* - NNN = 3-digit sequence per company+month
|
|
* - XXX = 3-letter company prefix from the order's company
|
|
*
|
|
* Per-prefix scoped sequence — each company has its own running
|
|
* counter within the month, so two POs to different companies in
|
|
* the same month are `PO202608001MAS` and `PO202608001NSS` rather
|
|
* than colliding on a global counter.
|
|
*
|
|
* Examples:
|
|
* Master Drilling in Aug 2026, 1st PO → `PO202608001MAS`
|
|
* Cash Sales in Aug 2026, 1st PO → `PO202608001CAS`
|
|
* Lifelong Res. in Aug 2026, 1st PO → `PO202608001LIF`
|
|
*
|
|
* Existing POs in any other format (`PO-2026-08-NNN` legacy, or
|
|
* `PO-2026-08-MAS-001` from the earlier dashed variant) are ignored
|
|
* by the prefix-aware lookup, so they don't participate in the
|
|
* new sequence.
|
|
*
|
|
* Server-side variant of the client `generatePONumber` in
|
|
* `pb-helpers.ts`. Lives here per the data-access discipline rules
|
|
* (server-side business logic stays out of pb-helpers.ts).
|
|
*
|
|
* Race note: two concurrent calls read the same highest seq and both
|
|
* return N+1. That happened 34 times by 2026-10-19 (one order, several
|
|
* suppliers, POs created within a second). Don't create a PO with this
|
|
* directly — use `createPurchaseOrderWithNumber`, which numbers and
|
|
* creates one PO at a time and retries on the unique index.
|
|
*/
|
|
export async function generatePONumber(orderId: string): Promise<string> {
|
|
const admin = await getAdminPocketBase();
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const dateKey = `${year}${month}`;
|
|
|
|
// Resolve the order's company name. If the lookup fails (deleted
|
|
// order, missing relation) we fall back to "GEN" so the format
|
|
// stays valid and the PO can still be created.
|
|
let companyName = "";
|
|
try {
|
|
const order = await admin.collection("orders").getOne(orderId, {
|
|
expand: "company",
|
|
});
|
|
companyName = order.expand?.company?.company ?? "";
|
|
} catch {
|
|
// Order not found or no company — fall through to the GEN prefix.
|
|
}
|
|
const prefix = companyPrefix(companyName);
|
|
// PO{YYYYMM}{PREFIX} — search key. We can't use this as a strict
|
|
// substring match (PB's `~` is a substring match and won't see
|
|
// through `PO{YYYYMM}001{PREFIX}`), so we filter on the date
|
|
// segment and post-filter the response on the prefix.
|
|
const dateSegment = `PO${dateKey}`;
|
|
|
|
const existing = await admin.collection("purchase_orders").getList<{
|
|
po_number: string;
|
|
}>(1, 500, {
|
|
filter: `po_number ~ "${dateSegment}"`,
|
|
sort: "-po_number",
|
|
});
|
|
|
|
// Take only the rows whose suffix matches our company prefix.
|
|
// The new format is fixed-length (2+6+3+3 = 14 chars), so the
|
|
// suffix is the last 3 characters of `po_number`.
|
|
let maxSeq = 0;
|
|
for (const row of existing.items) {
|
|
if (!row.po_number.endsWith(prefix)) continue;
|
|
const seqMatch = row.po_number.match(/(\d{3})(?=[A-Z]{3}$)/);
|
|
if (seqMatch) {
|
|
const n = parseInt(seqMatch[1], 10);
|
|
if (n > maxSeq) maxSeq = n;
|
|
}
|
|
}
|
|
|
|
const seq = maxSeq + 1;
|
|
return `${dateSegment}${String(seq).padStart(3, "0")}${prefix}`;
|
|
}
|
|
|
|
// ─── Numbered create ────────────────────────────────────────────────
|
|
|
|
/** One PO numbered + created at a time in this server (see the race note). */
|
|
let poQueue: Promise<unknown> = Promise.resolve();
|
|
function oneAtATime<T>(fn: () => Promise<T>): Promise<T> {
|
|
const run = poQueue.then(fn, fn);
|
|
poQueue = run.catch(() => undefined);
|
|
return run;
|
|
}
|
|
|
|
const MAX_ATTEMPTS = 5;
|
|
|
|
/** PB's answer when the unique index on po_number rejects a create. */
|
|
function isPoNumberClash(err: unknown): boolean {
|
|
const e = err as { response?: { data?: Record<string, { code?: string }> }; data?: { data?: Record<string, { code?: string }> } };
|
|
const data = e?.response?.data ?? e?.data?.data;
|
|
return data?.po_number?.code === "validation_not_unique";
|
|
}
|
|
|
|
/**
|
|
* Number a new customer PO and create it — the only way POs should be
|
|
* created. Calls run one after another, so the next call reads the
|
|
* number this one just wrote; if another server got there first, the
|
|
* unique index on `po_number` rejects the create and we take the next
|
|
* number (up to 5 tries). Any `po_number` in `fields` is ignored.
|
|
*/
|
|
export async function createPurchaseOrderWithNumber(
|
|
orderId: string,
|
|
fields: Record<string, unknown>,
|
|
): Promise<Record<string, unknown> & { id: string; po_number: string }> {
|
|
return oneAtATime(async () => {
|
|
const admin = await getAdminPocketBase();
|
|
for (let attempt = 1; ; attempt += 1) {
|
|
const poNumber = await generatePONumber(orderId);
|
|
try {
|
|
return await admin.collection("purchase_orders").create<Record<string, unknown> & { id: string; po_number: string }>({
|
|
...fields,
|
|
order: orderId,
|
|
po_number: poNumber,
|
|
});
|
|
} catch (err) {
|
|
if (!isPoNumberClash(err) || attempt >= MAX_ATTEMPTS) throw err;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|