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 { 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 = Promise.resolve(); function oneAtATime(fn: () => Promise): Promise { 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 }; data?: { data?: Record } }; 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, ): Promise & { 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 & { id: string; po_number: string }>({ ...fields, order: orderId, po_number: poNumber, }); } catch (err) { if (!isPoNumberClash(err) || attempt >= MAX_ATTEMPTS) throw err; } } }); }