From 3cf2837c3fead1b8861418b700c261d8a61b7609 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 12:46:13 +0200 Subject: [PATCH] feat(orders): add delivery-note repeat-order detection service Wraps the pure comparator with PB reads/writes. Same shape as suggestQuoteForOrder: - skip when order has no company (don't stamp attempted) - write potential_delivery_note + number + confidence + method + attempted:true on completion - empty write when no candidate matched Tie-break order: 1. priced quote match (status=delivery_note, selling_price == order) 2. most recent (created desc) Co-Authored-By: Claude --- .../order-delivery-note-link-service.test.ts | 227 ++++++++++++++++++ .../order-delivery-note-link-service.ts | 184 ++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 src/lib/server/order-delivery-note-link-service.test.ts create mode 100644 src/lib/server/order-delivery-note-link-service.ts diff --git a/src/lib/server/order-delivery-note-link-service.test.ts b/src/lib/server/order-delivery-note-link-service.test.ts new file mode 100644 index 0000000..9cf5bed --- /dev/null +++ b/src/lib/server/order-delivery-note-link-service.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +const ORDERS = "orders"; +const ORDER_ITEMS = "order_items"; +const DELIVERY_NOTES = "delivery_notes"; +const QUOTES = "quotes"; +const QUOTE_ITEMS = "quote_items"; + +interface CollectionMock { + getOne?: ReturnType; + getFullList?: ReturnType; + update?: ReturnType; +} + +function makePb(overrides: Partial> = {}) { + const defaults: Record = { + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "co1" }), + update: vi.fn().mockResolvedValue({}), + }, + [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + [DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) }, + [QUOTES]: { getFullList: vi.fn().mockResolvedValue([]) }, + [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }; + const merged: Record = { ...defaults, ...overrides }; + return { + autoCancellation: vi.fn(), + collection: (name: string) => { + const c = merged[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +function mockAdminPb(pb: ReturnType) { + vi.mocked(getAdminPocketBase).mockResolvedValue( + pb as unknown as Awaited>, + ); +} + +const ORDER = { id: "ord1", company: "co1" }; + +describe("suggestDeliveryNoteForOrder", () => { + beforeEach(() => vi.clearAllMocks()); + + it("skips entirely when the order has no company (does not stamp attempted)", async () => { + // When there's no company, we deliberately don't stamp + // `delivery_note_matching_attempted` — a future sweep (or the + // operator adding a company later) should still get a chance + // to match this order. Distinct from "we tried and found nothing". + const update = vi.fn().mockResolvedValue({}); + mockAdminPb( + makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "" }), + update, + }, + }), + ); + + const res = await suggestDeliveryNoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.best).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it("flags a single exact item+quantity match", async () => { + const update = vi.fn().mockResolvedValue({}); + mockAdminPb( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, + [ORDER_ITEMS]: { + // The service calls getFullList twice: once for the + // order's own items, then for each candidate DN's + // items. First mock value = order items, subsequent + // mocks = DN items as they're fetched. + getFullList: vi + .fn() + // 1st call: order's own items + .mockResolvedValueOnce([ + { id: "oi1", description: "Hex Bolt M20", quantity: 50 }, + ]) + // 2nd call: DN candidate's items + .mockResolvedValueOnce([ + { id: "di1", description: "hex bolt m20", quantity: 50 }, + ]), + }, + [DELIVERY_NOTES]: { + getFullList: vi.fn().mockResolvedValue([ + { + id: "dn1", + delivery_note_number: "DN26071401", + company: "co1", + status: "invoiced", + created: "2026-07-14T00:00:00Z", + }, + ]), + }, + }), + ); + + const res = await suggestDeliveryNoteForOrder("ord1"); + + expect(res.method).toBe("exact_items"); + expect(res.best).toEqual({ + deliveryNoteId: "dn1", + deliveryNoteNumber: "DN26071401", + }); + expect(update).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ + potential_delivery_note: "dn1", + potential_delivery_note_number: "DN26071401", + delivery_note_match_confidence: 1, + delivery_note_match_method: "exact_items", + delivery_note_matching_attempted: true, + }), + ); + }); + + it("stamps attempted:true and clears fields on no_match", async () => { + const update = vi.fn().mockResolvedValue({}); + mockAdminPb( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, + [ORDER_ITEMS]: { + getFullList: vi + .fn() + .mockResolvedValueOnce([ + { id: "oi1", description: "Widget", quantity: 1 }, + ]), + }, + [DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await suggestDeliveryNoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.best).toBeNull(); + expect(update).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ + potential_delivery_note: null, + potential_delivery_note_number: "", + delivery_note_match_confidence: 0, + delivery_note_match_method: "no_match", + delivery_note_matching_attempted: true, + }), + ); + }); + + it("breaks a tie between two exact-matching DNs using the priced quote", async () => { + // Two DN candidates match the order exactly on items. dn2 has a + // priced quote whose line prices match the order's; dn1 has no + // priced quote. The service should prefer dn2. + const orderItems = [ + { id: "oi1", description: "Hex Bolt M20", quantity: 50, selling_price: 12 }, + ]; + const update = vi.fn().mockResolvedValue({}); + mockAdminPb( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, + [ORDER_ITEMS]: { + getFullList: vi + .fn() + .mockResolvedValueOnce(orderItems) // order's own items + .mockResolvedValueOnce([ + { id: "d1", description: "hex bolt m20", quantity: 50 }, + ]) // dn1 items + .mockResolvedValueOnce([ + { id: "d2", description: "hex bolt m20", quantity: 50 }, + ]), // dn2 items + }, + [DELIVERY_NOTES]: { + getFullList: vi.fn().mockResolvedValue([ + { + id: "dn1", + delivery_note_number: "DN1", + company: "co1", + status: "invoiced", + created: "2026-05-01T00:00:00Z", + }, + { + id: "dn2", + delivery_note_number: "DN2", + company: "co1", + status: "invoiced", + created: "2026-07-01T00:00:00Z", + }, + ]), + }, + [QUOTES]: { + getFullList: vi + .fn() + .mockResolvedValueOnce([]) // dn1: no priced quote + .mockResolvedValueOnce([{ id: "q2", delivery_note: "dn2" }]), // dn2: yes + }, + [QUOTE_ITEMS]: { + getFullList: vi + .fn() + .mockResolvedValueOnce([ + { + id: "qi1", + quote: "q2", + description: "hex bolt m20", + selling_price: 12, + }, + ]), + }, + }), + ); + + const res = await suggestDeliveryNoteForOrder("ord1"); + + expect(res.best?.deliveryNoteId).toBe("dn2"); + }); +}); diff --git a/src/lib/server/order-delivery-note-link-service.ts b/src/lib/server/order-delivery-note-link-service.ts new file mode 100644 index 0000000..6c9032c --- /dev/null +++ b/src/lib/server/order-delivery-note-link-service.ts @@ -0,0 +1,184 @@ +/** + * 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"; + +/** DNs the customer has actually seen. A draft was never sent, a + * cancelled one was abandoned — neither can be "repeated." */ +const CANDIDATE_STATUSES = ["sent", "po_received", "invoiced", "completed"]; + +function esc(s: string): string { + return (s || "").replace(/"/g, '\\"'); +} + +interface DnCandidate { + id: string; + delivery_note_number: string; + company?: string; + status: string; + created: 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 candidates = await pb + .collection(DELIVERY_NOTES_COLLECTION) + .getFullList({ + filter: `company = "${esc(order.company)}" && (${CANDIDATE_STATUSES.map((s) => `status = "${s}"`).join(" || ")})`, + fields: "id,delivery_note_number,company,status,created", + batch: 1000, + }); + + const matched: DnCandidate[] = []; + for (const dn of candidates) { + const dnItemRows = await pb + .collection(ORDER_ITEMS_COLLECTION) + .getFullList<{ description?: string; quantity?: number }>({ + filter: `delivery_note_number = "${esc(dn.delivery_note_number)}"`, + fields: "description,quantity", + batch: 1000, + }); + 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; +}