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.
99 lines
No EOL
3.9 KiB
TypeScript
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}`;
|
|
} |