39 KiB
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
- This plan depends on
docs/superpowers/plans/2026-08-29-order-quote-linking.mdTask 6 being merged first — specifically therunQuoteMatchclosure insidecreateOrder'safter()block insrc/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 secondafter()call as a workaround. - Confirm
getOrdersDashboardData's field selection. Readsrc/lib/server/orders-service.tsaround line 253 (thepotential_quoteexpand-drop comment) and trace what the function actually returns to/api/v1/orders/list— full records or afields-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. - Confirm the migration script auth pattern still works.
scripts/migrations/2026-06-27-add-order-quote-match-fields.tsis 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).
/* 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):
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 inpackage.json:
"pb:migrate:order-delivery-note-match": "npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts",
- Step 5: Commit
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
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
/**
* 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
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
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
/**
* 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
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:
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:
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:
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
runQuoteMatchinorders-service.tsand edit it:
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:
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
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 --noEmitclean.- 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
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
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
Orderinterface insrc/lib/types.ts, alongside the existingpotential_quote/potential_quote_confidencefields:
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):
{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
getOrdersDashboardDataactually 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
expandrequest appears in the network tab for this field (confirming the no-extra-round-trip design goal actually holds) -
Step 5: Commit
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.