/** * order-delivery-note-link-service — detects when a new order is an * exact repeat of a past delivery note's items, scoped to the same * company (not customer — a different person at the same company can * place the repeat order). * * 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. * * Called from inside createOrder's existing after()/E468 guard * (orders-service.ts) — this function lets errors propagate to that * caller rather than double-guarding. */ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match"; export type DeliveryNoteMatchMethod = "exact_items" | "no_match"; export interface SuggestDeliveryNoteResult { orderId: string; best: { deliveryNoteId: string; deliveryNoteNumber: string } | null; method: DeliveryNoteMatchMethod; } const ORDER_COLLECTION = "orders"; const ORDER_ITEMS_COLLECTION = "order_items"; const DELIVERY_NOTES_COLLECTION = "delivery_notes"; const QUOTES_COLLECTION = "quotes"; const QUOTE_ITEMS_COLLECTION = "quote_items"; /** Maximum age (in days) of a delivery-note candidate considered for * repeat matching. Beyond this window, even an exact-line match is * treated as a coincidence, not a repeat order. */ 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 40 chars (`delivery_note_number = "" || `), * so 200 candidates keeps the filter at ~8 KB — at the limit. Using * 200 still collapses ~190 individual fetches per chunk while keeping * every individual filter comfortably parseable. */ const BULK_FILTER_CHUNK_SIZE = 100; // Per 2026-08-30 business decision: match against a DN of ANY status, // including `cancelled` (previously excluded along with a hardcoded // allowlist of "active" statuses — see git history) — the prior // exclusion missed real repeat orders where the original delivery's DN // had been cancelled for reasons unrelated to whether goods went out. // A 60-day candidate window (computed below) bounds the search space // instead. function esc(s: string): string { return (s || "").replace(/"/g, '\\"'); } interface DnCandidate { id: string; delivery_note_number: string; company?: string; status: string; created: string; /** The order this DN's items originated from (Path A). Empty for * ad-hoc counter DNs and for combined DNs spanning several orders. * Read solely to skip an order's own delivery note — see the guard * in the candidate loop. */ order?: string; } export async function suggestDeliveryNoteForOrder( orderId: string, ): Promise { const pb = await getAdminPocketBase(); pb.autoCancellation(false); const order = await pb .collection(ORDER_COLLECTION) .getOne<{ id: string; company?: string }>(orderId); if (!order.company) { // No signal to match on — deliberately does not write // `delivery_note_matching_attempted`, unlike the quote matcher's // "no_match" path. A never-attempted order (no company at all) is // a different state from "we looked and found nothing," and a // company gets added later, so leaving `attempted: false` lets a // future sweep (out of scope for this iteration) reconsider it. return { orderId, best: null, method: "no_match" }; } const orderItemRows = await pb .collection(ORDER_ITEMS_COLLECTION) .getFullList<{ id: string; description?: string; quantity?: number; selling_price?: number; }>({ filter: `order = "${esc(orderId)}"`, fields: "id,description,quantity,selling_price", batch: 1000, }); const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({ description: r.description || "", quantity: Number(r.quantity) || 0, })); const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000; const cutoff = new Date(Date.now() - WINDOW_MS); const cutoffStr = cutoff.toISOString().replace("T", " "); const candidates = await pb .collection(DELIVERY_NOTES_COLLECTION) .getFullList({ filter: `company = "${esc(order.company)}" && created >= "${cutoffStr}"`, fields: "id,delivery_note_number,company,status,created,order", batch: 1000, }); // Self-skip DNs that belong to the order being matched — both the // `dn.order === orderId` direct case and the combined-DN case where // any of the DN's items point back to this order. Both checks need // the DN's items, which we fetch ONCE below. const candidatesToFetch = candidates.filter((dn) => !(dn.order && dn.order === orderId)); let matched: DnCandidate[] = []; if (candidatesToFetch.length > 0) { // Bulk-fetch ALL items for ALL candidate DNs, 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 ~8 KB. delivery_note_number is the join key — // group rows by it. const itemsByDn = new Map>(); for (let i = 0; i < candidatesToFetch.length; i += BULK_FILTER_CHUNK_SIZE) { const chunk = candidatesToFetch.slice(i, i + BULK_FILTER_CHUNK_SIZE); const chunkItems = await pb .collection(ORDER_ITEMS_COLLECTION) .getFullList<{ order?: string; delivery_note_number?: string; description?: string; quantity?: number }>({ filter: chunk.map((d) => `delivery_note_number = "${esc(d.delivery_note_number)}"`).join(" || "), fields: "order,delivery_note_number,description,quantity", batch: 5000, }); for (const row of chunkItems) { if (!row.delivery_note_number) continue; const bucket = itemsByDn.get(row.delivery_note_number); if (bucket) bucket.push(row); else itemsByDn.set(row.delivery_note_number, [row]); } } for (const dn of candidatesToFetch) { const dnItemRows = itemsByDn.get(dn.delivery_note_number) ?? []; // Second half of the self-skip: a combined DN (see // `combineDeliveryNotes`) has `dn.order` empty because its items // span several orders. The id check above can't catch those, so // fall back to checking whether any of the DN's rows belong to the // order we're matching. if (dnItemRows.some((r) => r.order && r.order === orderId)) continue; const dnLines: DeliveryLine[] = dnItemRows.map((r) => ({ description: r.description || "", quantity: Number(r.quantity) || 0, })); if (isExactItemMatch(orderLines, dnLines)) matched.push(dn); } } let best: DnCandidate | null = null; if (matched.length === 1) { best = matched[0]; } else if (matched.length > 1) { best = (await breakTieByQuotePrice(pb, matched, orderItemRows)) ?? mostRecent(matched); } await pb.collection(ORDER_COLLECTION).update(orderId, { potential_delivery_note: best ? best.id : null, potential_delivery_note_number: best ? best.delivery_note_number : "", delivery_note_match_confidence: best ? 1 : 0, delivery_note_match_method: best ? "exact_items" : "no_match", delivery_note_matching_attempted: true, }); return { orderId, best: best ? { deliveryNoteId: best.id, deliveryNoteNumber: best.delivery_note_number } : null, method: best ? "exact_items" : "no_match", }; } function mostRecent(candidates: DnCandidate[]): DnCandidate { return [...candidates].sort((a, b) => (b.created || "").localeCompare(a.created || ""), )[0]; } /** Among tied exact-item-match candidates, prefer the one whose priced * quote (status="delivery_note") also matches the order's line * prices. Returns null if no candidate's quote prices match — caller * falls back to most-recent. */ async function breakTieByQuotePrice( pb: Awaited>, candidates: DnCandidate[], orderItemRows: Array<{ description?: string; selling_price?: number }>, ): Promise { const priceByDesc = new Map(); for (const r of orderItemRows) { priceByDesc.set( (r.description || "").trim().toLowerCase(), Number(r.selling_price) || 0, ); } for (const dn of candidates) { const quotes = await pb .collection(QUOTES_COLLECTION) .getFullList<{ id: string; delivery_note: string }>({ filter: `delivery_note = "${esc(dn.id)}" && status = "delivery_note"`, fields: "id,delivery_note", batch: 5, }); if (quotes.length === 0) continue; const quoteItems = await pb .collection(QUOTE_ITEMS_COLLECTION) .getFullList<{ description?: string; selling_price?: number }>({ filter: `quote = "${esc(quotes[0].id)}"`, fields: "description,selling_price", batch: 1000, }); const allPricesMatch = quoteItems.every((qi) => { const orderPrice = priceByDesc.get((qi.description || "").trim().toLowerCase()); return orderPrice !== undefined && orderPrice === (Number(qi.selling_price) || 0); }); if (allPricesMatch && quoteItems.length > 0) return dn; } return null; }