# Order ↔ delivery-note repeat-order detection **Date:** 2026-08-29 **Status:** Draft — written directly to spec form per the user's request to save review cycles; assumptions are called out explicitly below rather than resolved through interactive brainstorming. Confirm or correct before handing to an implementer. ## Problem A recurring flow: an order is picked up and processed, a delivery note (DN) is raised for it, a quote is generated from that DN and sent to the customer, and the customer later sends back a fresh order for the same goods — a repeat of that delivery. Today nothing connects the new order back to the DN it repeats. The operator has no visual signal that "this order is basically DN26071401 again," even though the evidence is already in the data. ## What already exists (confirmed by reading the code, not assumed) - `delivery_notes` collection (`src/lib/types.ts:1069`): `delivery_note_number` (unique, format `DN{YY}{MM}{DD}{NN}`), `customer`, `company`, `order` (optional — the *original* order the items came from, Path A only), `status` (`draft → sent → po_received → invoiced → completed`, or `cancelled`), `delivery_date`, `customer_po`. - **DNs have no separate line-item collection.** Items are `order_items` rows tagged by string equality: `order_items.delivery_note_number === delivery_notes.delivery_note_number` (`delivery-notes-service.ts:141`, `:197`). Each such row carries `description`, `quantity`, `selling_price`. - `createQuoteFromDeliveryNote` (`delivery-notes-service.ts`, the `sent` transition) creates a `quotes` row with `status: "delivery_note"` and sets `quotes.delivery_note` (relation, `types.ts:268`) back to the DN. This is "the quote we sent with the DN" the request refers to. - The order↔quote auto-linking feature just built (`docs/superpowers/plans/2026-08-29-order-quote-linking.md`) gives a template to follow: a pure matcher module, a PB-backed service, a hook into `createOrder`'s existing `after()` block, and a colored pill on `/orders`. This feature reuses that shape rather than inventing a new one. ## Assumptions made to keep this a single, boundable spec State these back if any is wrong — each would change the design materially. 1. **Match scope is `company`, per the request** ("customer won't work, someone else at the company can place the order"). `delivery_notes.company` is used directly — it does not need to be derived by joining through the DN's original order. 2. **The primary signal is an exact match, not a fuzzy score.** "Items and quantities match 100%" is read as a literal set-equality test: every order line pairs to exactly one DN line with the same (normalized) description and the same quantity, every DN line is paired, and no line is left over on either side. This is deliberately stricter than the quote matcher's Jaccard scoring — there is no "possible" tier here. Either it is a complete match or it isn't shown at all. 3. **The quote-price signal is corroborating, not required.** Read from "the first one will be the items exactly matches... because the order will be an exact match": item+quantity equality alone is sufficient to flag. The DN's priced quote is used only to break ties when an order's items match more than one DN exactly (a customer who reorders the same thing repeatedly). 4. **This is visibility only.** No change to order processing, pricing, supplier assignment, or any automation triggered by a match. A wrong flag costs nothing — the operator just ignores the pill. This justifies writing the match automatically (no human confirmation step), unlike the quote matcher's deliberately conservative "suggest, don't link" policy — that policy existed because a wrong quote link puts wrong prices in front of a buyer; nothing analogous is at stake here. 5. **Candidate DNs are scoped to `status` in `sent | po_received | invoiced | completed`.** A `draft` DN was never sent to the customer, so an order can't be "repeating" it yet. `cancelled` is excluded as abandoned. 6. **No backlog sweep in this iteration.** Only orders created from now on get checked, mirroring how the quote-matching sweep was scoped as a separate, later concern. Can be added the same way later if wanted. 7. **Detection piggybacks on the existing `createOrder` → `after()` hook** rather than adding a second `after()` call, to reuse the already-reviewed E468 guard rather than duplicating it. **Correction to §3 while drafting this document — read before implementing:** `getOrdersDashboardData` in `orders-service.ts` (the function backing the `/api/v1/orders/list` route the orders page actually calls — the page does *not* expand relations client-side) already made this exact tradeoff once for `potential_quote` and documented it. Section 2 below covers the fix (a denormalized number field); flagging it here too since it's the kind of thing an implementer skimming past the schema section could miss and then wire the pill to `order.expand?.potential_delivery_note` anyway, silently reintroducing the cost this spec exists partly to avoid. ## Design ### 1. Pure comparison — `src/lib/server/delivery-note-order-match.ts` No PocketBase, unit-testable in isolation, mirroring the separation already established in `order-quote-match.ts`. ```ts export interface DeliveryLine { description: string; quantity: number; } /** Normalize for comparison: trim, lowercase, collapse internal whitespace. * Not the quote matcher's tokenize/Jaccard — this is exact-string equality * on a normalized form, not fuzzy overlap. */ export function normalizeLine(s: string): string; /** * True iff every order line pairs to exactly one DN line with an equal * normalized description and equal quantity, every DN line is paired, and * neither side has anything left over. Order of lines does not matter * (multiset equality, not sequence equality) — duplicate lines are handled * by matching multiplicity, not just membership. */ export function isExactItemMatch( orderLines: ReadonlyArray, dnLines: ReadonlyArray, ): boolean; ``` ### 2. Schema — new fields on `orders`, via migration script Following the exact precedent of `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` (idempotent, `pb.collections.update` with a field-existence check, run as PB superuser). **No PB admin UI step is needed** — these are relation/text/bool/number fields, not `select` fields, so the `AGENTS.md` warning about manually adding enum values in the PB admin does not apply here. - `potential_delivery_note` — relation to `delivery_notes`, single, optional. Mirrors the shape of the existing `orders.potential_quote`. For programmatic use (joins, the on-demand re-check route, a future sweep) — not read by the list pill (see the note below on why). - `potential_delivery_note_number` — text, optional. **Denormalized copy** of `delivery_notes.delivery_note_number`, written at the same time as the relation. This is what the list pill actually renders. See rationale below — this field exists specifically to avoid a second `expand` cost on the orders list. - `delivery_note_match_confidence` — number, optional. Always `1` when set; kept for shape-consistency with the quote matcher and so a future fuzzy extension doesn't need a second migration. - `delivery_note_match_method` — text, optional. `"exact_items"` or `"no_match"`. Text, not select, for the same forward-compatibility reason `quote_match_method` is text. - `delivery_note_matching_attempted` — bool, optional, default `false`. **Why a denormalized number field, corrected from an earlier draft of this spec:** `getOrdersDashboardData` (`orders-service.ts:253`) deliberately drops `potential_quote` from its `expand` — the comment there states PB issues a separate `IN` query per expanded relation, so the existing "Suggested" quote pill shows only a static label, not the quote number, specifically to avoid that extra round-trip on every list load and every realtime refetch. Adding a second expanded relation (`potential_delivery_note`) would double that cost the codebase already went out of its way to avoid. But the request specifically asks for the DN *number* to be visible on the pill — unlike the quote pill's generic "Suggested" label, this one is supposed to read `DN26071401`. Denormalizing the number as a plain text field sidesteps the tradeoff entirely: the list query needs zero additional expands, and `delivery_note_number` is immutable once assigned (server- generated, never edited per its type doc in `types.ts:1071-1078`), so there is no staleness risk from copying it. `suggestDeliveryNoteForOrder` writes both fields in the same update. ### 3. Service — `src/lib/server/order-delivery-note-link-service.ts` ```ts export interface SuggestDeliveryNoteResult { orderId: string; best: { deliveryNoteId: string; deliveryNoteNumber: string } | null; method: "exact_items" | "no_match"; } export async function suggestDeliveryNoteForOrder( orderId: string, ): Promise; ``` Behaviour: 1. Load the order and its `order_items` (description, quantity). 2. Query candidate `delivery_notes` where `company = order.company` and `status` in the scoped set (§Assumption 5). Skip entirely if `order.company` is empty — no signal to match on. 3. For each candidate, fetch its items (`order_items` where `delivery_note_number = candidate.delivery_note_number`) and run `isExactItemMatch`. 4. If exactly one candidate matches, that's `best`. 5. **If more than one candidate matches** (the tie case): for each tied candidate, find its priced quote (`quotes` where `delivery_note = candidate.id` and `status = "delivery_note"`), compare its `quote_items` selling prices against the order's line prices for the same paired descriptions. Prefer the candidate whose prices also match; if still tied, prefer the most recently created DN. 6. Write `potential_delivery_note`, `potential_delivery_note_number`, `delivery_note_match_confidence`, `delivery_note_match_method`, `delivery_note_matching_attempted` — always stamping `attempted: true` even on `no_match` (with the other fields cleared to null/empty), mirroring the quote matcher's audit convention. 7. **Never throws out to the caller** — this is called from inside the existing `createOrder` → `after()` guard, which already handles logging a swallowed failure. This function itself should let errors propagate to that caller rather than double-guarding. ### 4. Wiring into intake In `orders-service.ts`, inside the existing `runQuoteMatch` closure built for Task 6 of the quote-linking plan (`orders-service.ts:2226-2235`), add a second call alongside `suggestQuoteForOrder`: ```ts async function runQuoteMatch() { try { await suggestQuoteForOrder(order.id); } catch (err) { console.error(`[createOrder] quote auto-detection failed for order ${order.id}:`, err); } try { await suggestDeliveryNoteForOrder(order.id); } catch (err) { console.error(`[createOrder] delivery-note match failed for order ${order.id}:`, err); } } ``` Two independent `try/catch` blocks, not one wrapping both — a delivery-note match failure must not prevent the quote suggestion from landing, and vice versa. Reuses the single `after()`/E468 guard already reviewed; does not add a second `after()` call. ### 5. On-demand re-check (optional but recommended) `POST /api/v1/orders/[id]/delivery-note-match` — same shape as the quote matcher's single-order re-check endpoint, for the case where an order's items were edited after intake. Thin wrapper calling `suggestDeliveryNoteForOrder(id)`. ### 6. UI — orders list pill `src/app/(app)/orders/page.tsx`, in the same badge row as the existing quote pills (`:701-735`). New pill, additive — an order can show both a quote pill and a delivery-note pill at once, they are independent facts. Reads the denormalized `potential_delivery_note_number` text field from §2 — **no `expand` needed, no extra query cost**: ```tsx {order.potential_delivery_note_number && ( {order.potential_delivery_note_number} )} ``` Blue, distinct from the quote pills' teal (linked) and amber (suggested) — this is a different kind of fact (a repeat-order observation, not a provenance link) and should not read as a variant of the quote pill. `potential_delivery_note_number` needs adding to `OrderWithItems` / `Order` in `src/lib/types.ts`, and to whatever field allowlist (if any) `getOrdersDashboardData` uses for the orders it returns — verify at implementation time whether that function selects specific fields or returns full records; §3's note about PB 0.26 dropping `expand` when `fields` is set suggests it currently returns full records, in which case no allowlist change is needed, but confirm rather than assume. ## Out of scope - Any change to order processing, pricing, or supplier auto-fill based on the match. - A backlog sweep over existing orders (§Assumption 6). - Surfacing the match on the order detail or process pages — list-only for this iteration, since that's what was asked for ("visible... a pill"). - Human confirmation / promotion flow — there is no soft/hard distinction here, unlike the quote matcher (§Assumption 4). ## Testing - Pure comparison: unit tests for exact match, count mismatch, quantity mismatch, description-only-differs-by-case-or-whitespace (should still match), and duplicate-line multiplicity (two lines of the same item must require two DN lines, not one). - Service: mocked-PB tests mirroring `order-quote-link-service.test.ts`'s harness style — single match, tie broken by price, tie broken by recency, no match, empty company skips entirely. - Intake wiring: a test proving a delivery-note match failure doesn't prevent the quote suggestion (and vice versa) — the two-try/catch requirement in §4 is the point of this test. - UI: manual verification in the browser against a real order+DN pair.