/** * 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; /** PB rejects filter expressions longer than ~8 KB (HTTP 400 "Something * went wrong while processing your request."). Each candidate adds * roughly 30 chars (`order = "<15-char-id>" || `), so 200 candidates * keeps the filter at ~6 KB — comfortably under the limit while still * collapsing ~190 individual fetches per chunk. */ const BULK_FILTER_CHUNK_SIZE = 100; 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", " "); } 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; // Bulk-fetch ALL items for ALL candidates, chunked to stay under PB's // filter-expression length limit. The chunked query still collapses // N per-candidate getFullList calls into ceil(N/200) — ~190x faster // than the old N+1 loop, while never producing a single filter larger // than ~6 KB. Group items by candidate order id so we can compare each // candidate's lines to the source order's lines in memory. if (candidates.length > 0) { const itemsByOrder = new Map>(); for (let i = 0; i < candidates.length; i += BULK_FILTER_CHUNK_SIZE) { const chunk = candidates.slice(i, i + BULK_FILTER_CHUNK_SIZE); const chunkItems = await pb .collection(ORDER_ITEMS_COLLECTION) .getFullList<{ order?: string; description?: string; quantity?: number }>({ filter: chunk.map((c) => `order = "${esc(c.id)}"`).join(" || "), fields: "order,description,quantity", batch: 5000, }); for (const row of chunkItems) { if (!row.order) continue; const bucket = itemsByOrder.get(row.order); if (bucket) bucket.push(row); else itemsByOrder.set(row.order, [row]); } } for (const candidate of candidates) { const candidateItemRows = itemsByOrder.get(candidate.id) ?? []; 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", }; }