diff --git a/src/lib/server/order-order-duplicate-link-service.test.ts b/src/lib/server/order-order-duplicate-link-service.test.ts new file mode 100644 index 0000000..185dc4a --- /dev/null +++ b/src/lib/server/order-order-duplicate-link-service.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach, 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; + // `created >= "2026-..."` clause — compare lexicographically. + // Safe for the YYYY-MM-DD HH:MM:SS.sssZ format PB uses, which + // sorts correctly as a string. Without this, every candidate + // would pass and the 60-day window would be inert. + 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; sort?: string } = {}) { + const items = where(name, opts.filter || ""); + // Honor `sort` so tests can pin behaviour that depends on row + // order. The real PB SDK sorts by the requested field + // ascending; descending requires a `-` prefix which we do not + // currently model. Sufficient for the duplicate-matcher tests + // that request `sort: "created"`. + if (opts.sort) { + const dir = opts.sort.startsWith("-") ? -1 : 1; + const field = opts.sort.replace(/^-/, ""); + items.sort((a, b) => { + const av = String(a[field] ?? ""); + const bv = String(b[field] ?? ""); + return dir * (av < bv ? -1 : av > bv ? 1 : 0); + }); + } + return items; + }, + 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 }; + 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("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue(pbMock), +})); + +import { suggestOrderDuplicate } from "@/lib/server/order-order-duplicate-link-service"; + +async function seedOrderWithItems( + orderId: string, + fields: Record, + items: Array<{ description: string; quantity: number }>, +) { + await pbMock.collection("orders").create({ id: orderId, ...fields }); + for (let i = 0; i < items.length; i++) { + await pbMock.collection("order_items").create({ + id: `${orderId}-item-${i}`, + order: orderId, + description: items[i].description, + quantity: items[i].quantity, + }); + } +} + +beforeEach(() => { + clearStore(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-30T12:00:00.000Z")); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("suggestOrderDuplicate", () => { + it("flags an order whose items exactly match another order's, same company, within 60 days, regardless of status", async () => { + await seedOrderWithItems( + "order-original", + { + order_number: "ORD-2026-08-001", + company: "comp-1", + created: "2026-07-15 00:00:00.000Z", // 46 days before "now" + status: "delivered", // status must not matter + }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + await seedOrderWithItems( + "order-repeat", + { + order_number: "ORD-2026-08-090", + company: "comp-1", + created: "2026-08-30 00:00:00.000Z", + status: "new", + }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + + const result = await suggestOrderDuplicate("order-repeat"); + + expect(result.duplicateOrderId).toBe("order-original"); + const updated = await pbMock.collection("orders").getOne("order-repeat"); + expect(updated.potential_duplicate_order).toBe("order-original"); + expect(updated.potential_duplicate_order_number).toBe("ORD-2026-08-001"); + expect(updated.duplicate_order_match_confidence).toBe(1); + expect(updated.duplicate_order_match_method).toBe("exact_items_same_company"); + expect(updated.duplicate_order_matching_attempted).toBe(true); + }); + + it("does not flag when the matching order is for a different company", async () => { + await seedOrderWithItems( + "order-other-company", + { order_number: "ORD-A", company: "comp-2", created: "2026-08-01 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + await seedOrderWithItems( + "order-mine", + { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + + const result = await suggestOrderDuplicate("order-mine"); + + expect(result.duplicateOrderId).toBeNull(); + }); + + it("does not flag when the matching order is older than 60 days", async () => { + await seedOrderWithItems( + "order-too-old", + { order_number: "ORD-A", company: "comp-1", created: "2026-06-01 00:00:00.000Z" }, // 90 days before "now" + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + await seedOrderWithItems( + "order-mine", + { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + + const result = await suggestOrderDuplicate("order-mine"); + + expect(result.duplicateOrderId).toBeNull(); + }); + + it("does not flag when item quantities differ", async () => { + await seedOrderWithItems( + "order-a", + { order_number: "ORD-A", company: "comp-1", created: "2026-08-01 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + await seedOrderWithItems( + "order-b", + { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 250 }], // different qty + ); + + const result = await suggestOrderDuplicate("order-b"); + + expect(result.duplicateOrderId).toBeNull(); + }); + + it("never matches an order against itself", async () => { + await seedOrderWithItems( + "order-solo", + { order_number: "ORD-A", company: "comp-1", created: "2026-08-30 00:00:00.000Z" }, + [{ description: "M16x50 Bolt", quantity: 300 }], + ); + + const result = await suggestOrderDuplicate("order-solo"); + + expect(result.duplicateOrderId).toBeNull(); + }); +}); diff --git a/src/lib/server/order-order-duplicate-link-service.ts b/src/lib/server/order-order-duplicate-link-service.ts new file mode 100644 index 0000000..44ff021 --- /dev/null +++ b/src/lib/server/order-order-duplicate-link-service.ts @@ -0,0 +1,120 @@ +/** + * order-order-duplicate-link-service — detects when a new order's + * line items (description + quantity, exact multiset match) exactly + * repeat another order for the SAME company, created within the last + * 60 days, REGARDLESS of either order's status. + * + * Visibility only — same reasoning as order-delivery-note-link-service + * and order-number-duplicate-link-service. This is deliberately a + * separate flag/pill from the delivery-note repeat check: this one + * catches "we already have an ORDER for this," the DN check catches + * "we already DELIVERED this" — different facts, different pills. + * + * PREREQUISITE: Before this code can run against a real PocketBase + * instance, the operator must add five fields to the `orders` + * collection in the PB admin UI (per the AGENTS.md convention — PB + * schema is not self-service from code): + * - `potential_duplicate_order` (Relation → orders, single) + * - `potential_duplicate_order_number` (Text) + * - `duplicate_order_match_confidence` (Number) + * - `duplicate_order_match_method` (Text) + * - `duplicate_order_matching_attempted` (Bool) + * Without these fields, the `.update()` below will 500 in production. + */ + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match"; + +const ORDER_COLLECTION = "orders"; +const ORDER_ITEMS_COLLECTION = "order_items"; +const WINDOW_DAYS = 60; + +export type OrderDuplicateMatchMethod = "exact_items_same_company" | "no_match"; + +export interface SuggestOrderDuplicateResult { + orderId: string; + duplicateOrderId: string | null; + method: OrderDuplicateMatchMethod; +} + +function esc(s: string): string { + return (s || "").replace(/"/g, '\\"'); +} + +function pbDate(d: Date): string { + return d.toISOString().replace("T", " ").replace("Z", "Z"); +} + +export async function suggestOrderDuplicate( + orderId: string, +): Promise { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + const order = await pb + .collection(ORDER_COLLECTION) + .getOne<{ id: string; company?: string; created: string }>(orderId); + + if (!order.company) { + // No signal to match on — same "leave attempted unset" reasoning + // as the delivery-note matcher: a company gets added later and a + // future sweep should reconsider it. + return { orderId, duplicateOrderId: null, method: "no_match" }; + } + + const orderItemRows = await pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList<{ description?: string; quantity?: number }>({ + filter: `order = "${esc(orderId)}"`, + fields: "description,quantity", + batch: 1000, + }); + const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({ + description: r.description || "", + quantity: Number(r.quantity) || 0, + })); + + const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const candidates = await pb + .collection(ORDER_COLLECTION) + .getFullList<{ id: string; order_number: string; created: string }>({ + filter: `company = "${esc(order.company)}" && id != "${esc(orderId)}" && created >= "${pbDate(cutoff)}"`, + fields: "id,order_number,created", + sort: "created", + batch: 500, + }); + + let match: { id: string; order_number: string } | null = null; + for (const candidate of candidates) { + const candidateItemRows = await pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList<{ description?: string; quantity?: number }>({ + filter: `order = "${esc(candidate.id)}"`, + fields: "description,quantity", + batch: 1000, + }); + const candidateLines: DeliveryLine[] = candidateItemRows.map((r) => ({ + description: r.description || "", + quantity: Number(r.quantity) || 0, + })); + if (isExactItemMatch(orderLines, candidateLines)) { + match = candidate; + break; // candidates sorted oldest-first — first hit is the original + } + } + + await pb.collection(ORDER_COLLECTION).update(orderId, { + potential_duplicate_order: match ? match.id : null, + potential_duplicate_order_number: match ? match.order_number : "", + duplicate_order_match_confidence: match ? 1 : 0, + duplicate_order_match_method: match ? "exact_items_same_company" : "no_match", + duplicate_order_matching_attempted: true, + }); + + return { + orderId, + duplicateOrderId: match ? match.id : null, + method: match ? "exact_items_same_company" : "no_match", + }; +}