WFDASH-WEBAPP/src/lib/server/po-number.ts
Dave 999948861e feat: add server-side quote search & PO number format update
Implement new server-side ranked quote search endpoint at `/api/v1/quotes/search`, replacing the legacy 500-row client-side substring scan that missed older quotes and didn't rank results by relevance. The new search scores matches across quote identifiers, customer/company headers, and line item fields.
Update PO number generation and validation to use a dashless format (POYYYYMMNNNXXX) with per-company scoped sequence numbers, and ignore legacy PO formats when calculating new sequences.
Remove legacy `searchQuotesWithItems` function and its associated test file, then update the quotes page to use the new server-side search API.
Add comprehensive unit and integration tests for the new quote search service and updated PO number logic.
2026-08-14 11:15:38 +02:00

99 lines
No EOL
3.9 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 could read the same highest seq
* and both write N+1, producing duplicate PO numbers. The call sites
* that need stronger guarantees should wrap this in a transaction or
* retry-on-conflict — for the buyer-tasks flow the user sees the
* duplicate and can regenerate, so we keep the simple version.
*/
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}`;
}