WFDASH-WEBAPP/docs/superpowers/plans/2026-08-29-order-delivery-note-match.md

982 lines
39 KiB
Markdown
Raw Normal View History

# 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.