WFDASH-WEBAPP/src/lib/server/po-number.ts
Dave f017a7f985 fix: number customer POs one at a time (duplicate PO numbers)
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>
2026-10-19 15:08:12 +02:00

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;
}
}
});
}