docs(orders): spec + plan for delivery-note repeat-order detection

This commit is contained in:
Dave 2026-08-29 23:51:52 +02:00
parent 21bc57797b
commit 0083199ed2
2 changed files with 1275 additions and 0 deletions

View file

@ -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<string, unknown> & { 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<DeliveryLine>,
dnLines: ReadonlyArray<DeliveryLine>,
): 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<string, number>();
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<typeof vi.fn>;
getFullList?: ReturnType<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
}
const DEFAULTS: Record<string, CollectionMock> = {
[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<Record<string, CollectionMock>> = {}) {
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<typeof makePb>) {
vi.mocked(getAdminPocketBase).mockResolvedValue(
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
);
}
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<SuggestDeliveryNoteResult> {
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<DnCandidate>({
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<ReturnType<typeof getAdminPocketBase>>,
candidates: DnCandidate[],
orderItemRows: Array<{ description?: string; selling_price?: number }>,
): Promise<DnCandidate | null> {
const priceByDesc = new Map<string, number>();
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 && (
<span
title={`Matches delivery note ${order.potential_delivery_note_number} — likely a repeat order`}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
background: "rgba(59, 130, 246, 0.10)",
color: "#3b82f6",
boxShadow: "inset 0 0 0 1px rgba(59, 130, 246, 0.30)",
}}
aria-label={`Matches delivery note ${order.potential_delivery_note_number}`}
>
<Truck className="h-3 w-3" />
{order.potential_delivery_note_number}
</span>
)}
```
`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.

View file

@ -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<DeliveryLine>,
dnLines: ReadonlyArray<DeliveryLine>,
): 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<SuggestDeliveryNoteResult>;
```
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 && (
<span
title={`Matches delivery note ${order.potential_delivery_note_number} — likely a repeat order`}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{
background: "rgba(59, 130, 246, 0.10)",
color: "#3b82f6",
boxShadow: "inset 0 0 0 1px rgba(59, 130, 246, 0.30)",
}}
aria-label={`Matches delivery note ${order.potential_delivery_note_number}`}
>
<Truck className="h-3 w-3" />
{order.potential_delivery_note_number}
</span>
)}
```
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.