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.
263 lines
No EOL
9.8 KiB
TypeScript
263 lines
No EOL
9.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
// Mock the admin pb module before importing the SUT so the mock is
|
|
// in place when the module evaluates.
|
|
const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|
type StoreMap = Map<string, Record<string, unknown>>;
|
|
const store: Record<string, StoreMap> = {};
|
|
let seq = 0;
|
|
const nextId = () => `mock-${++seq}`;
|
|
const getOrCreateMap = (name: string): StoreMap => {
|
|
if (!store[name]) store[name] = new Map();
|
|
return store[name];
|
|
};
|
|
const matchesClause = (item: Record<string, unknown>, clause: string): boolean => {
|
|
const m = clause.match(/^(\w+)\s*(=~|!~|~|=|!=)\s*"?([^"]+?)"?$/);
|
|
if (!m) return true;
|
|
const [, field, op, raw] = m;
|
|
const expected = raw.replace(/^"|"$/g, "");
|
|
const actual = item[field];
|
|
const actualStr = String(actual ?? "");
|
|
switch (op) {
|
|
case "=": return actualStr === expected;
|
|
case "!=": return actualStr !== expected;
|
|
case "=~": return actualStr.includes(expected);
|
|
case "~": return actualStr.includes(expected);
|
|
case "!~": return !actualStr.includes(expected);
|
|
default: return true;
|
|
}
|
|
};
|
|
const where = (name: string, filter: string): Record<string, unknown>[] => {
|
|
const items = Array.from(getOrCreateMap(name).values());
|
|
if (!filter || !filter.trim()) return items;
|
|
return items.filter((item) => {
|
|
const orParts = filter.split("||").map((s) => s.trim());
|
|
return orParts.some((part) => {
|
|
const andParts = part.split("&&").map((s) => s.trim());
|
|
return andParts.every((p) => matchesClause(item, p));
|
|
});
|
|
});
|
|
};
|
|
const pbMock = {
|
|
autoCancellation: () => undefined,
|
|
collection: (name: string) => ({
|
|
async getList(
|
|
page = 1,
|
|
perPage = 25,
|
|
opts: { filter?: string; sort?: string } = {},
|
|
) {
|
|
const all = where(name, opts.filter || "");
|
|
if (opts.sort) {
|
|
const sign = opts.sort.startsWith("-") ? -1 : 1;
|
|
const field = opts.sort.replace(/^-/, "");
|
|
all.sort((a, b) => {
|
|
const av = a[field] as string | number | undefined;
|
|
const bv = b[field] as string | number | undefined;
|
|
if (av === bv) return 0;
|
|
if (av === undefined) return 1;
|
|
if (bv === undefined) return -1;
|
|
return (av > bv ? 1 : -1) * sign;
|
|
});
|
|
}
|
|
const totalItems = all.length;
|
|
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
|
|
const start = (page - 1) * perPage;
|
|
const items = all.slice(start, start + perPage);
|
|
return { items, totalItems, totalPages, page, perPage };
|
|
},
|
|
async getFullList(opts: { filter?: string; fields?: string; expand?: string } = {}) {
|
|
const items = where(name, opts.filter || "");
|
|
if (!opts.expand) return items;
|
|
const relations = opts.expand.split(",").map((s) => s.trim()).filter(Boolean);
|
|
return items.map((it) => {
|
|
const expand: Record<string, unknown> = {};
|
|
for (const rel of relations) {
|
|
const relId = it[rel];
|
|
const IRREGULAR: Record<string, string> = { company: "companies" };
|
|
const coll = IRREGULAR[rel] ?? (rel.endsWith("s") ? rel : `${rel}s`);
|
|
if (typeof relId === "string" && getOrCreateMap(coll).has(relId)) {
|
|
expand[rel] = getOrCreateMap(coll).get(relId);
|
|
}
|
|
}
|
|
return { ...it, expand };
|
|
});
|
|
},
|
|
async getOne(id: string, opts: { expand?: string } = {}) {
|
|
const item = getOrCreateMap(name).get(id);
|
|
if (!item) {
|
|
const err = new Error("not found") as Error & { status: number };
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
if (!opts.expand) return item;
|
|
const relations = opts.expand.split(",").map((s) => s.trim()).filter(Boolean);
|
|
const expand: Record<string, unknown> = {};
|
|
for (const rel of relations) {
|
|
const relId = item[rel];
|
|
const IRREGULAR: Record<string, string> = { company: "companies" };
|
|
const coll = IRREGULAR[rel] ?? (rel.endsWith("s") ? rel : `${rel}s`);
|
|
if (typeof relId === "string" && getOrCreateMap(coll).has(relId)) {
|
|
expand[rel] = getOrCreateMap(coll).get(relId);
|
|
}
|
|
}
|
|
return { ...item, expand };
|
|
},
|
|
async create(data: Record<string, unknown>) {
|
|
const id = typeof data.id === "string" ? data.id : nextId();
|
|
const item = {
|
|
id,
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
...data,
|
|
};
|
|
getOrCreateMap(name).set(id, item);
|
|
return item;
|
|
},
|
|
async update(id: string, data: Record<string, unknown>) {
|
|
const map = getOrCreateMap(name);
|
|
const existing = map.get(id);
|
|
if (!existing) {
|
|
const err = new Error("not found") as Error & { status: number };
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
const updated = { ...existing, ...data, updated: new Date().toISOString() };
|
|
map.set(id, updated);
|
|
return updated;
|
|
},
|
|
}),
|
|
};
|
|
const clearStore = () => {
|
|
for (const k of Object.keys(store)) store[k].clear();
|
|
};
|
|
return { store, pbMock, clearStore };
|
|
});
|
|
|
|
vi.mock("./pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
}));
|
|
|
|
import { companyPrefix, generatePONumber } from "./po-number";
|
|
|
|
beforeEach(() => {
|
|
clearStore();
|
|
});
|
|
|
|
describe("companyPrefix", () => {
|
|
it("takes the first 3 letters of the first word, uppercase", () => {
|
|
expect(companyPrefix("Master Drilling")).toBe("MAS");
|
|
expect(companyPrefix("Cash Sales")).toBe("CAS");
|
|
expect(companyPrefix("NSS Fastners")).toBe("NSS");
|
|
expect(companyPrefix("Harmony - Gold Mining Company Limited")).toBe("HAR");
|
|
expect(companyPrefix("ESAB")).toBe("ESA");
|
|
});
|
|
|
|
it("strips non-alphanumeric characters before slicing", () => {
|
|
expect(companyPrefix("A.B.C Trading")).toBe("ABC");
|
|
// Unicode letters (e.g. accented) fall through to the ASCII
|
|
// prefix — the operator can rename the company in PB admin if
|
|
// they want a different prefix. The important contract is that
|
|
// we always produce a 3-char uppercase result.
|
|
expect(companyPrefix("abc-def")).toBe("ABC");
|
|
});
|
|
|
|
it("pads with X when the first word is shorter than 3 chars", () => {
|
|
expect(companyPrefix("AB Holdings")).toBe("ABX");
|
|
});
|
|
|
|
it("falls back to GEN when the company name is empty or missing", () => {
|
|
expect(companyPrefix("")).toBe("GEN");
|
|
expect(companyPrefix(null)).toBe("GEN");
|
|
expect(companyPrefix(undefined)).toBe("GEN");
|
|
});
|
|
});
|
|
|
|
describe("generatePONumber", () => {
|
|
async function seedOrder(orderId: string, companyName: string, companyId?: string) {
|
|
const coId = companyId ?? `co-${orderId}`;
|
|
await pbMock.collection("companies").create({ id: coId, company: companyName });
|
|
await pbMock.collection("orders").create({
|
|
id: orderId,
|
|
order_number: "ORD-2026-08-001",
|
|
company: coId,
|
|
});
|
|
}
|
|
|
|
// Persist a PO so the next call's sequence lookup can find it.
|
|
async function seedPO(poNumber: string) {
|
|
await pbMock.collection("purchase_orders").create({
|
|
po_number: poNumber,
|
|
});
|
|
}
|
|
|
|
it("builds the no-dash format with prefix", async () => {
|
|
await seedOrder("o-mas", "Master Drilling");
|
|
const po = await generatePONumber("o-mas");
|
|
// Format: PO + YYYYMM + NNN + 3-letter prefix (no dashes).
|
|
// 6 digits for YYYYMM (no day), 3 digits for the sequence, 3
|
|
// letters for the prefix, total 14 chars.
|
|
expect(po).toMatch(/^PO\d{6}001MAS$/);
|
|
});
|
|
|
|
it("uses per-company scoped sequence", async () => {
|
|
await seedOrder("o-mas", "Master Drilling");
|
|
await seedOrder("o-cas", "Cash Sales");
|
|
const mas1 = await generatePONumber("o-mas");
|
|
const cas1 = await generatePONumber("o-cas");
|
|
// Different companies must each get their own sequence 001 —
|
|
// not a global counter that would give them 001 and 002.
|
|
expect(mas1).toMatch(/001MAS$/);
|
|
expect(cas1).toMatch(/001CAS$/);
|
|
});
|
|
|
|
it("increments within the same company/month", async () => {
|
|
await seedOrder("o-mas", "Master Drilling");
|
|
const a = await generatePONumber("o-mas");
|
|
await seedPO(a);
|
|
const b = await generatePONumber("o-mas");
|
|
await seedPO(b);
|
|
const c = await generatePONumber("o-mas");
|
|
expect(a).toMatch(/001MAS$/);
|
|
expect(b).toMatch(/002MAS$/);
|
|
expect(c).toMatch(/003MAS$/);
|
|
});
|
|
|
|
it("uses the GEN prefix when the order has no company", async () => {
|
|
await pbMock.collection("orders").create({
|
|
id: "o-no-comp",
|
|
order_number: "ORD-2026-08-001",
|
|
});
|
|
const po = await generatePONumber("o-no-comp");
|
|
expect(po).toMatch(/001GEN$/);
|
|
});
|
|
|
|
it("falls back to GEN when the order lookup fails", async () => {
|
|
const po = await generatePONumber("o-does-not-exist");
|
|
expect(po).toMatch(/001GEN$/);
|
|
});
|
|
|
|
it("ignores legacy dashed POs when computing the sequence", async () => {
|
|
// Legacy dashed POs and the earlier dashed-prefix format
|
|
// (PO-2026-08-MAS-001) should be excluded so the new no-dash
|
|
// format starts its own sequence at 001.
|
|
await seedOrder("o-mas", "Master Drilling");
|
|
await pbMock.collection("purchase_orders").create({
|
|
po_number: "PO-2026-08-127",
|
|
});
|
|
await pbMock.collection("purchase_orders").create({
|
|
po_number: "PO-2026-08-MAS-001",
|
|
});
|
|
const po = await generatePONumber("o-mas");
|
|
expect(po).toMatch(/001MAS$/);
|
|
});
|
|
|
|
it("does not count POs from other companies in the same month", async () => {
|
|
// Even though CAS has 005, MAS still starts at 001.
|
|
await seedOrder("o-mas", "Master Drilling");
|
|
await seedOrder("o-cas", "Cash Sales");
|
|
await seedPO("PO202608001CAS");
|
|
await seedPO("PO202608005CAS");
|
|
const mas1 = await generatePONumber("o-mas");
|
|
expect(mas1).toMatch(/001MAS$/);
|
|
});
|
|
}); |