diff --git a/src/lib/server/order-number-duplicate-link-service.test.ts b/src/lib/server/order-number-duplicate-link-service.test.ts new file mode 100644 index 0000000..08db1d4 --- /dev/null +++ b/src/lib/server/order-number-duplicate-link-service.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { store, pbMock, clearStore } = vi.hoisted(() => { + type StoreMap = Map>; + const store: Record = {}; + let seq = 0; + const nextId = () => `mock-${++seq}`; + + const getOrCreateMap = (name: string): StoreMap => { + if (!store[name]) store[name] = new Map(); + return store[name]; + }; + + const where = (name: string, filter: string): Record[] => { + const items = Array.from(getOrCreateMap(name).values()); + if (!filter || !filter.trim()) return items; + const clauses = filter.split("&&").map((c) => c.trim()); + return items.filter((item) => + clauses.every((clause) => { + const m = clause.match(/^(\w+)\s*([!=]+)\s*"?([^"]+)"?$/); + if (!m) return true; + const [, field, op, raw] = m; + const value = raw.replace(/^"|"$/g, ""); + const actual = String(item[field] ?? ""); + if (op === "=") return actual === value; + if (op === "!=") return actual !== value; + return true; + }), + ); + }; + + const pbMock = { + autoCancellation: vi.fn(), + collection: (name: string) => ({ + autoCancellation: vi.fn(), + async getList( + _page = 1, + _perPage = 25, + opts: { filter?: string } = {}, + ) { + const items = where(name, opts.filter || ""); + return { items, totalItems: items.length, page: 1, perPage: _perPage }; + }, + async getFullList(opts: { filter?: string } = {}) { + return where(name, opts.filter || ""); + }, + async getOne( + id: string, + opts: { expand?: string } = {}, + ): Promise & { expand?: Record }> { + 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 }; + // Honor the `expand` option: attach each comma-separated + // relation as a nested `expand.` on the returned + // record. Matches the real PocketBase SDK shape (consumer + // code reads `record.expand?.quote`), not a flat + // `expand_` sentinel. + // + // The mock's collection map is keyed on the actual PB + // collection name (e.g. `quotes`, plural), while relation + // names in `expand` are singular (e.g. `quote`). Try the + // relation name as-is first, then the pluralised form, so + // tests can populate the related collection with the name + // they already use. + const expandObj: Record = {}; + for (const rel of opts.expand.split(",").map((s) => s.trim()).filter(Boolean)) { + const relatedId = (item as Record)[rel]; + if (typeof relatedId !== "string" || !relatedId) continue; + const related = + getOrCreateMap(rel).get(relatedId) ?? + getOrCreateMap(`${rel}s`).get(relatedId) ?? + getOrCreateMap(rel.replace(/s$/, "")).get(relatedId); + if (related) expandObj[rel] = { ...related }; + } + return { ...item, expand: expandObj }; + }, + async create(data: Record) { + const item = { + id: nextId(), + created: new Date().toISOString(), + updated: new Date().toISOString(), + ...data, + }; + getOrCreateMap(name).set(item.id, item); + return item; + }, + async update(id: string, data: Record) { + 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; + } + for (const [key, value] of Object.entries(data)) { + existing[key] = value; + } + existing.updated = new Date().toISOString(); + return { ...existing }; + }, + }), + }; + + const clearStore = () => { + for (const key of Object.keys(store)) delete store[key]; + seq = 0; + }; + + return { store, pbMock, clearStore }; +}); + +vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) })); +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +})); + +import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service"; + +beforeEach(() => clearStore()); + +describe("suggestOrderNumberDuplicate", () => { + it("flags two orders that share the same order_number, regardless of status", async () => { + await pbMock.collection("orders").create({ + id: "order-a", + order_number: "NO-REF", + status: "delivered", + }); + const orderB = await pbMock.collection("orders").create({ + id: "order-b", + order_number: "NO-REF", + status: "new", + }); + + const result = await suggestOrderNumberDuplicate(orderB.id); + + expect(result.duplicateOrderId).toBe("order-a"); + const updated = await pbMock.collection("orders").getOne("order-b"); + expect(updated.order_number_duplicate).toBe("order-a"); + expect(updated.order_number_duplicate_checked).toBe(true); + }); + + it("does not flag when order_number is unique", async () => { + const order = await pbMock.collection("orders").create({ + id: "order-solo", + order_number: "UNIQUE-123", + }); + + const result = await suggestOrderNumberDuplicate(order.id); + + expect(result.duplicateOrderId).toBeNull(); + const updated = await pbMock.collection("orders").getOne("order-solo"); + expect(updated.order_number_duplicate).toBeFalsy(); + expect(updated.order_number_duplicate_checked).toBe(true); + }); + + it("prefers the oldest other order when more than two orders share a number", async () => { + await pbMock.collection("orders").create({ + id: "order-oldest", + order_number: "SHARED", + created: "2026-01-01 00:00:00.000Z", + }); + await pbMock.collection("orders").create({ + id: "order-middle", + order_number: "SHARED", + created: "2026-02-01 00:00:00.000Z", + }); + const orderNewest = await pbMock.collection("orders").create({ + id: "order-newest", + order_number: "SHARED", + created: "2026-03-01 00:00:00.000Z", + }); + + const result = await suggestOrderNumberDuplicate(orderNewest.id); + + expect(result.duplicateOrderId).toBe("order-oldest"); + }); +}); \ No newline at end of file diff --git a/src/lib/server/order-number-duplicate-link-service.ts b/src/lib/server/order-number-duplicate-link-service.ts new file mode 100644 index 0000000..eb28b10 --- /dev/null +++ b/src/lib/server/order-number-duplicate-link-service.ts @@ -0,0 +1,68 @@ +/** + * order-number-duplicate-link-service — detects when an order's + * `order_number` is reused by ANOTHER order (any status, no time + * limit). Catches two cases that have bitten the operator: + * - internal ORD-number collisions from a partial intake race + * - operator not realising a PO was already entered + * + * Visibility only. Nothing downstream reads this to drive pricing, + * supplier assignment, or processing — a wrong flag costs nothing, the + * operator just ignores the pill. That's what justifies writing the + * match automatically with no human confirmation step, unlike the + * order↔quote matcher's deliberately conservative suggest-only policy + * (see order-quote-link-service.ts) — that policy exists because a + * wrong quote link puts wrong prices in front of a buyer. + * + * NOTE: customer PO refs (e.g. "PO-12345") and placeholders like + * "NO-REF" are ALLOWED to repeat by design — one blanket customer PO + * is often split into several partial-delivery orders. This matcher + * flags any exact-string duplicate regardless of shape; the order + * review collision check (orders-service.ts) is the only place that + * gates on ORD-YYYY-MM-NNN shape. + */ + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +const ORDER_COLLECTION = "orders"; + +export interface OrderNumberDuplicateResult { + orderId: string; + duplicateOrderId: string | null; +} + +function esc(s: string): string { + return (s || "").replace(/"/g, '\\"'); +} + +export async function suggestOrderNumberDuplicate( + orderId: string, +): Promise { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const order = await pb + .collection(ORDER_COLLECTION) + .getOne<{ id: string; order_number?: string }>(orderId); + + if (!order.order_number) { + return { orderId, duplicateOrderId: null }; + } + + const others = await pb + .collection(ORDER_COLLECTION) + .getFullList<{ id: string; created: string }>({ + filter: `order_number = "${esc(order.order_number)}" && id != "${esc(orderId)}"`, + fields: "id,created", + sort: "created", + batch: 500, + }); + + const duplicateOrderId = others.length > 0 ? others[0].id : null; + + await pb.collection(ORDER_COLLECTION).update(orderId, { + order_number_duplicate: duplicateOrderId, + order_number_duplicate_checked: true, + }); + + return { orderId, duplicateOrderId }; +} \ No newline at end of file