diff --git a/docs/superpowers/plans/2026-08-29-order-delivery-note-match.md b/docs/superpowers/plans/2026-08-29-order-delivery-note-match.md new file mode 100644 index 0000000..7cd25d0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-order-delivery-note-match.md @@ -0,0 +1,981 @@ +# Order ↔ Delivery-Note Repeat-Order Detection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When a new order's line items exactly match a past delivery note's items (same company), flag it automatically with a visible pill on the orders list showing the delivery note number. + +**Architecture:** A pure exact-match comparator (no I/O) feeds a PB-backed service, `suggestDeliveryNoteForOrder`, which piggybacks on the `after()` hook already built and reviewed for the order↔quote matching feature — no new `after()` call, no new error-guard code to write or review. Two independent `try/catch` blocks so a failure in one matcher can't block the other. The match is written automatically (no human confirmation step) because it's informational only — nothing downstream reads it to drive pricing or processing. + +**Tech Stack:** Next.js 16.2.6 (App Router), TypeScript, PocketBase (via `getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react. + +**Spec:** `docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md` — read it first. It records why the pill reads a denormalized text field instead of an expanded relation (a deliberate perf tradeoff already made once in this codebase for the quote-suggestion pill, at `orders-service.ts:253`) — do not "simplify" this by switching to an `expand`. + +--- + +## Pre-flight — verify before writing code, do not assume + +1. **This plan depends on `docs/superpowers/plans/2026-08-29-order-quote-linking.md` Task 6 being merged first** — specifically the `runQuoteMatch` closure inside `createOrder`'s `after()` block in `src/lib/server/orders-service.ts`. Confirm it exists (`grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts`) before starting Task 4 below. If it isn't there yet, stop and report — do not build a second `after()` call as a workaround. +2. **Confirm `getOrdersDashboardData`'s field selection.** Read `src/lib/server/orders-service.ts` around line 253 (the `potential_quote` expand-drop comment) and trace what the function actually returns to `/api/v1/orders/list` — full records or a `fields`-limited projection. The spec assumes full records (per the PB-0.26-drops-expand-when-fields-is-set note in the same comment block) so no allowlist change is needed for the new field to appear. If that assumption is wrong, Task 6 below needs an additional edit — say so in that task's report. +3. **Confirm the migration script auth pattern still works.** `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` is the template for Task 1. Read it in full before writing the new one. + +## File structure + +| File | Responsibility | +|---|---| +| `src/lib/server/delivery-note-order-match.ts` *(create)* | Pure exact-match comparator. No I/O. | +| `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts` *(create)* | Adds the 5 new `orders` fields. | +| `src/lib/server/order-delivery-note-link-service.ts` *(create)* | PB-backed `suggestDeliveryNoteForOrder`. | +| `src/lib/server/orders-service.ts` *(modify)* | Add the second matcher call inside the existing `runQuoteMatch` closure. | +| `src/app/api/v1/orders/[id]/delivery-note-match/route.ts` *(create)* | On-demand single-order re-check. | +| `src/lib/types.ts` *(modify)* | Add the 5 new fields to the `Order` interface. | +| `src/app/(app)/orders/page.tsx` *(modify)* | New pill in the existing badge row. | + +## Phase boundaries + +- **Phase 1 — Tasks 1–4.** Backend: schema, matcher, service, intake wiring. Independently testable and mergeable without the UI. +- **Phase 2 — Tasks 5–6.** On-demand route + UI pill. + +--- + +# PHASE 1 — Backend + +## Task 1: Migration script for the new `orders` fields + +**Files:** +- Create: `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts` + +- [ ] **Step 1: Write the script** + +Model this exactly on `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` — same env-loading, same auth pattern, same idempotent field-existence check per field, same re-fetch-after-each-write pattern (PB's `collections.update` needs the freshest `fields` array or it clobbers concurrent additions). + +```ts +/* eslint-disable */ +/** + * Migration: add delivery-note repeat-order detection fields to `orders`. + * + * Adds five fields: + * + * 1. `potential_delivery_note` (relation → delivery_notes, single) + * The DN whose items exactly matched this order's items. + * + * 2. `potential_delivery_note_number` (text) + * Denormalized copy of the matched DN's `delivery_note_number`. + * Written alongside (1) so the orders list can render the number + * without a second `expand` — see the comment at + * orders-service.ts:253 for why that expand cost is avoided here + * (PB issues a separate IN query per expanded relation). + * `delivery_note_number` is immutable once assigned, so copying + * it carries no staleness risk. + * + * 3. `delivery_note_match_confidence` (number) + * Always 1 when set — the match is exact-or-nothing, not scored. + * Kept for shape parity with quote_match_confidence and so a + * future fuzzy extension doesn't need a second migration. + * + * 4. `delivery_note_match_method` (text) + * "exact_items" or "no_match". Text, not select, for the same + * forward-compatibility reason quote_match_method is text. + * + * 5. `delivery_note_matching_attempted` (bool) + * Audit flag mirroring quote_matching_attempted. + * + * Idempotent — safe to re-run. Each step detects its own "already + * done" state. + * + * Run with: + * npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts + */ +import PocketBase from "pocketbase"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function loadEnvFile(name: string) { + try { + const raw = readFileSync(resolve(process.cwd(), name), "utf8"); + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i); + if (m && !process.env[m[1]]) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim(); + } + } + } catch { + // file not present — fine, rely on shell env + } +} +loadEnvFile(".env"); +loadEnvFile(".env.local"); + +async function main() { + const url = process.env.NEXT_PUBLIC_POCKETBASE_URL; + const email = process.env.PB_ADMIN_EMAIL; + const password = process.env.PB_ADMIN_PASSWORD; + if (!url || !email || !password) { + throw new Error( + "Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local)." + ); + } + + const pb = new PocketBase(url); + await pb.collection("_superusers").authWithPassword(email, password); + console.log(`[order-delivery-note-match] Authenticated against ${url}`); + + const deliveryNotes = await pb.collections.getOne("delivery_notes"); + + async function addFieldIfMissing( + fieldDef: Record & { name: string }, + ) { + const orders = await pb.collections.getOne("orders"); + if (orders.fields.some((f: { name: string }) => f.name === fieldDef.name)) { + console.log(`[order-delivery-note-match] orders.${fieldDef.name} already present — skipping.`); + return; + } + await pb.collections.update(orders.id, { + fields: [...orders.fields, fieldDef], + }); + console.log(`[order-delivery-note-match] Added orders.${fieldDef.name}.`); + } + + await addFieldIfMissing({ + name: "potential_delivery_note", + type: "relation", + required: false, + presentable: false, + options: { + collectionId: deliveryNotes.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + }, + }); + + await addFieldIfMissing({ + name: "potential_delivery_note_number", + type: "text", + required: false, + presentable: false, + options: { max: 32 }, + }); + + await addFieldIfMissing({ + name: "delivery_note_match_confidence", + type: "number", + required: false, + presentable: false, + options: { min: 0, max: 1 }, + }); + + await addFieldIfMissing({ + name: "delivery_note_match_method", + type: "text", + required: false, + presentable: false, + options: { max: 32 }, + }); + + await addFieldIfMissing({ + name: "delivery_note_matching_attempted", + type: "bool", + required: false, + presentable: false, + options: {}, + }); + + console.log("[order-delivery-note-match] Migration complete."); +} + +main().catch((err) => { + console.error("[order-delivery-note-match] Migration failed:", err); + process.exit(1); +}); +``` + +**Step 2: Verify the relation field's `options` shape against a real PB response.** +The `relation` field's `options` object (`collectionId`, `cascadeDelete`, `minSelect`, `maxSelect`) is written from memory of PocketBase's schema shape — before running this against production, fetch the *existing* `orders.quote` or `orders.potential_quote` field definition (`pb.collections.getOne("orders")`, inspect `.fields`) and confirm the option keys match exactly. If they differ, use the real shape, not what's written above. + +- [ ] **Step 3: Run it against the actual PB instance** (ask the user for confirmation before running any migration against a real database — this is a schema change, not a reversible local operation): + +```bash +npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts +``` + +Expected: five "Added orders.X" lines, or "already present" on re-run. + +- [ ] **Step 4: Add the npm script**, matching the existing `pb:migrate:*` convention in `package.json`: + +```json +"pb:migrate:order-delivery-note-match": "npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts", +``` + +- [ ] **Step 5: Commit** + +```bash +git add scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts package.json +git commit -m "feat(orders): add delivery-note repeat-order match fields" +``` + +--- + +## Task 2: Pure exact-match comparator + +**Files:** +- Create: `src/lib/server/delivery-note-order-match.ts` +- Test: `src/lib/server/delivery-note-order-match.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +import { describe, it, expect } from "vitest"; +import { normalizeLine, isExactItemMatch } from "@/lib/server/delivery-note-order-match"; + +describe("normalizeLine", () => { + it("trims, lowercases, and collapses internal whitespace", () => { + expect(normalizeLine(" Hex Bolt M20 ")).toBe("hex bolt m20"); + }); +}); + +describe("isExactItemMatch", () => { + it("matches when every line pairs 1:1 on normalized description and quantity", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Nut M20", quantity: 50 }, + ]; + const dn = [ + { description: "hex bolt m20", quantity: 50 }, + { description: "HEX NUT M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(true); + }); + + it("does not match when a quantity differs", () => { + const order = [{ description: "Hex Bolt M20", quantity: 50 }]; + const dn = [{ description: "Hex Bolt M20", quantity: 45 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("does not match when the order has an extra line the DN doesn't have", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Washer M20", quantity: 50 }, + ]; + const dn = [{ description: "Hex Bolt M20", quantity: 50 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("does not match when the DN has an extra line the order doesn't have", () => { + const order = [{ description: "Hex Bolt M20", quantity: 50 }]; + const dn = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Washer M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("requires matching multiplicity, not just membership, for duplicate lines", () => { + // Order has the SAME line twice (two separate 50-unit lines of the + // same bolt, e.g. from two different quote lines that priced + // differently but described identically). The DN has only one. + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + const dn = [{ description: "Hex Bolt M20", quantity: 50 }]; + expect(isExactItemMatch(order, dn)).toBe(false); + }); + + it("matches duplicate lines when both sides have the same multiplicity", () => { + const order = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + const dn = [ + { description: "Hex Bolt M20", quantity: 50 }, + { description: "Hex Bolt M20", quantity: 50 }, + ]; + expect(isExactItemMatch(order, dn)).toBe(true); + }); + + it("returns false for two empty line sets rather than vacuously true", () => { + // An order or DN with zero lines carries no signal — this must + // never be treated as "everything matched." If this returned true, + // an order with 0 lines could match every DN with 0 lines in the + // company, which is meaningless. + expect(isExactItemMatch([], [])).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run to verify red.** `npx vitest run src/lib/server/delivery-note-order-match.test.ts` + +- [ ] **Step 3: Implementation** + +```ts +/** + * delivery-note-order-match — pure exact-match comparator for the + * order ↔ delivery-note repeat-order detector. + * + * Deliberately NOT the quote matcher's tokenize/Jaccard fuzzy scoring + * (order-quote-match.ts). This is exact-string equality on a + * normalized form, with multiset (bag) semantics — every order line + * must pair to exactly one DN line and nothing may be left over on + * either side. There is no partial-credit tier here; either the order + * is a complete repeat of the DN or it isn't flagged at all. + */ + +export interface DeliveryLine { + description: string; + quantity: number; +} + +/** Normalize for comparison: trim, lowercase, collapse internal + * whitespace runs to a single space. */ +export function normalizeLine(s: string): string { + return (s || "").trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * True iff `orderLines` and `dnLines` are equal as multisets keyed on + * (normalized description, quantity). Empty input on either side + * always returns false — there is no signal to match on, and treating + * two empty sets as "matching" would flag every zero-line order + * against every zero-line DN. + */ +export function isExactItemMatch( + orderLines: ReadonlyArray, + dnLines: ReadonlyArray, +): boolean { + if (orderLines.length === 0 || dnLines.length === 0) return false; + if (orderLines.length !== dnLines.length) return false; + + const key = (l: DeliveryLine) => `${normalizeLine(l.description)}${l.quantity}`; + + const counts = new Map(); + for (const l of orderLines) { + const k = key(l); + counts.set(k, (counts.get(k) ?? 0) + 1); + } + for (const l of dnLines) { + const k = key(l); + const remaining = counts.get(k); + if (!remaining) return false; + counts.set(k, remaining - 1); + } + // Lengths already matched, so if every DN line found a counterpart, + // every order line was consumed too — no need to re-scan counts. + return true; +} +``` + +- [ ] **Step 4: Run tests, verify green.** All 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/delivery-note-order-match.ts src/lib/server/delivery-note-order-match.test.ts +git commit -m "feat(orders): add exact-match comparator for delivery-note detection" +``` + +--- + +## Task 3: `suggestDeliveryNoteForOrder` service + +**Files:** +- Create: `src/lib/server/order-delivery-note-link-service.ts` +- Test: `src/lib/server/order-delivery-note-link-service.test.ts` + +Model the mock harness on `src/lib/server/order-quote-link-service.test.ts`'s `makePb` (a defaults-plus-per-test-override map — read that file first, it was refactored specifically to avoid boilerplate as more tests accumulate; do not write a fresh ad-hoc mock). + +- [ ] **Step 1: Write the failing tests** + +```ts +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; +} + +const DEFAULTS: Record = { + [ORDERS]: { getOne: vi.fn(), 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([]) }, +}; + +function makePb(overrides: Partial> = {}) { + const merged = { ...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", async () => { + const update = vi.fn(); + mockAdminPb(makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "" }), update }, + })); + const res = await suggestDeliveryNoteForOrder("ord1"); + expect(res.method).toBe("no_match"); + 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]: { + getFullList: vi.fn() + // first call: the order's own items + .mockResolvedValueOnce([{ id: "oi1", description: "Hex Bolt M20", quantity: 50 }]) + // second call: candidate DN'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(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 () => { + 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 has no priced quote + .mockResolvedValueOnce([{ id: "q2", delivery_note: "dn2" }]), // dn2 does + }, + [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"); + }); +}); +``` + +**Note for the implementer:** the exact sequencing of `mockResolvedValueOnce` calls above depends on the order your implementation issues queries in. Write the implementation first to decide the natural query order, THEN align the mock call sequence to match — don't contort the implementation to satisfy a guessed mock order. If your natural implementation issues queries in a different sequence than assumed here, that's fine; update the test to match, and note the change in your report. + +- [ ] **Step 2: Run to verify red.** + +- [ ] **Step 3: Implementation** + +```ts +/** + * 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; +} +``` + +- [ ] **Step 4: Run tests, verify green.** + +- [ ] **Step 5: Typecheck and commit** + +```bash +npx tsc --noEmit +git add src/lib/server/order-delivery-note-link-service.ts src/lib/server/order-delivery-note-link-service.test.ts +git commit -m "feat(orders): add delivery-note repeat-order detection service" +``` + +--- + +## Task 4: Wire into intake + +**Files:** +- Modify: `src/lib/server/orders-service.ts` +- Test: `src/lib/server/orders-service.test.ts` + +- [ ] **Step 1: Confirm the pre-flight assumption.** Run: + +```bash +grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts +``` + +If this returns nothing, STOP — the order↔quote linking plan's Task 6 hasn't landed yet. Report BLOCKED rather than building a parallel `after()` call. + +- [ ] **Step 2: Write the failing test** + +Add `suggestDeliveryNoteForOrder` to the existing `vi.mock("./order-delivery-note-link-service", ...)` — check whether a mock for this module already exists in the test file (it won't, since this is the first task to import it); add one: + +```ts +vi.mock("./order-delivery-note-link-service", () => ({ + suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({ best: null, method: "no_match" }), +})); +``` + +Then append, reusing `pbMock` exactly as the existing `createOrder quote auto-detection` tests do: + +```ts +describe("createOrder delivery-note auto-detection", () => { + it("also schedules a delivery-note match after the response", async () => { + const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service"); + vi.mocked(suggestDeliveryNoteForOrder).mockClear(); + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const created = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("a delivery-note match failure does not prevent the quote suggestion from running", async () => { + const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service"); + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestDeliveryNoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce(new Error("DN lookup failed")); + + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const created = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id); + }); + + it("a quote-match failure does not prevent the delivery-note match from running", async () => { + const { suggestDeliveryNoteForOrder } = await import("./order-delivery-note-link-service"); + const { suggestQuoteForOrder } = await import("./order-quote-link-service"); + vi.mocked(suggestDeliveryNoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockClear(); + vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("quote lookup failed")); + + await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" }); + await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" }); + + const created = await createOrder({ + customer_id: "cust-x", + company_id: "comp-x", + items: [{ description: "M16 Bolt", quantity: 50 }], + }); + + expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id); + }); +}); +``` + +The second and third tests are the actual point of this task's design — proving the two `try/catch` blocks are genuinely independent, not just present. **Verify these are not vacuous** the same way earlier tasks in the sibling plan did: temporarily merge the two calls under one `try/catch` (so a `suggestDeliveryNoteForOrder` throw would prevent `suggestQuoteForOrder` from running), confirm test 2 fails, then restore the two-block structure. Report that result. + +- [ ] **Step 3: Confirm red**, then locate `runQuoteMatch` in `orders-service.ts` and edit it: + +```ts +async function runQuoteMatch() { + try { + await suggestQuoteForOrder(order.id); + } catch (err) { + console.error(`[createOrder] quote auto-detection failed for order ${order.id}:`, err instanceof Error ? err.message : err); + } + try { + await suggestDeliveryNoteForOrder(order.id); + } catch (err) { + console.error(`[createOrder] delivery-note match failed for order ${order.id}:`, err instanceof Error ? err.message : err); + } +} +``` + +(The exact current shape of `runQuoteMatch` may differ slightly after the sibling plan's Task 6 review fixes — read it fresh before editing rather than assuming the shape above is byte-exact. Preserve whatever logging/structure is already there for the quote half; add the delivery-note half alongside it with the same pattern.) + +Add the import: + +```ts +import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service"; +``` + +Match the file's relative-vs-aliased import convention — check what the adjacent `suggestQuoteForOrder`/`pairOrderLines` import uses and mirror it exactly. + +- [ ] **Step 4: Run tests, verify green.** Then run the full suite and diff failing test names against the known baseline (check the sibling plan's task reports for the current baseline count/names — it may have shifted slightly if that plan's later tasks have also landed). + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/orders-service.ts src/lib/server/orders-service.test.ts +git commit -m "feat(orders): detect delivery-note repeats at order intake" +``` + +--- + +## ⛔ Phase 1 checkpoint + +- [ ] Run the full suite: `npm run test:run`. +- [ ] `npx tsc --noEmit` clean. +- [ ] Confirm the migration ran against the real PB instance and the five fields exist (ask the user to confirm, or check via the PB admin UI — do not assume Task 1's script ran just because it's committed). + +--- + +# PHASE 2 — Surfacing + +## Task 5: On-demand re-check route + +**Files:** +- Create: `src/app/api/v1/orders/[id]/delivery-note-match/route.ts` + +- [ ] **Step 1: Implementation** + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service"; + +/** + * POST /api/v1/orders/[id]/delivery-note-match + * + * Re-run delivery-note repeat-order detection for one order on demand + * — for the case where an order's items were edited after intake, or + * the order predates this feature. Orders created from now on are + * checked automatically at intake (see createOrder in orders-service.ts). + * + * Visibility only — writes potential_delivery_note and its sibling + * fields, never touches order processing or pricing. + * + * Returns: SuggestDeliveryNoteResult — { orderId, best, method }. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const result = await suggestDeliveryNoteForOrder(id); + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Delivery-note match failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} +``` + +- [ ] **Step 2: Verify manually** against the dev server (port 4001) with a real order id. + +- [ ] **Step 3: Commit** + +```bash +git add "src/app/api/v1/orders/[id]/delivery-note-match/route.ts" +git commit -m "feat(orders): add on-demand delivery-note match re-check route" +``` + +--- + +## Task 6: Orders list pill + +**Files:** +- Modify: `src/lib/types.ts` +- Modify: `src/app/(app)/orders/page.tsx` + +- [ ] **Step 1: Add the field to the `Order` interface** in `src/lib/types.ts`, alongside the existing `potential_quote` / `potential_quote_confidence` fields: + +```ts + potential_delivery_note?: string; + potential_delivery_note_number?: string; +``` + +- [ ] **Step 2: Add the pill** in `src/app/(app)/orders/page.tsx`, in the same badge row as the quote pills (find the block ending around the existing `{!order.quote && order.potential_quote && (...)}` — insert after it, not replacing it): + +```tsx +{order.potential_delivery_note_number && ( + + + {order.potential_delivery_note_number} + +)} +``` + +`Truck` is already imported in this file (used elsewhere at line ~751) — do not add a duplicate import. + +- [ ] **Step 3: Verify `getOrdersDashboardData` actually returns the new field.** + +Per the pre-flight note, confirm this function returns full order records (no `fields` allowlist) rather than assuming. If it does use a `fields` projection somewhere in the pipeline, add `potential_delivery_note_number` to it. + +- [ ] **Step 4: Verify in the browser.** + +Start the dev server (port 4001), find or create an order whose `potential_delivery_note_number` is set (either via the intake flow or by manually setting it through the PB admin for testing), and confirm: +- the pill renders with the DN number +- it coexists correctly with a quote pill on the same order (test an order that has both) +- the tooltip reads correctly +- no `expand` request appears in the network tab for this field (confirming the no-extra-round-trip design goal actually holds) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/types.ts "src/app/(app)/orders/page.tsx" +git commit -m "feat(orders): show a repeat-order pill when an order matches a delivery note" +``` + +--- + +## Final verification + +- [ ] `npm run test:run` — full suite green, no new failures beyond the known pre-existing baseline. +- [ ] `npx tsc --noEmit && npm run lint` — clean. +- [ ] Confirm in the browser: an order created against a company with a matching past DN shows the pill; an order with no match shows nothing; an order matching two DNs with different quote prices picks the one whose price matches. diff --git a/docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md b/docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md new file mode 100644 index 0000000..7212f7c --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md @@ -0,0 +1,294 @@ +# 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.