WFDASH-WEBAPP/docs/superpowers/plans/2026-08-30-order-duplicate-detection.md
Dave a73f3b3f97 fix(orders): update order_number collision-checking logic
Refine the handling of order_number values in the MCP API. Custom free-text order_numbers are now allowed to repeat across orders without triggering collision checks, while only values formatted as ORD-YYYY-MM-NNN will be subject to collision verification. Update tests to reflect this new behavior and ensure proper functionality.
2026-08-31 01:24:56 +02:00

41 KiB

Order Duplicate 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: Automatically flag three kinds of duplicate orders at intake time — (1) exact order_number reuse, any status, no time limit; (2) exact same line items (description + quantity) for the same company, any status, within the last 60 days; (3) exact same line items matching a past delivery note for the same company, any DN status including cancelled, within the last 60 days — each surfaced as its own pill on the /orders list, and backfillable onto existing historical orders.

Architecture: Two new lightweight "link-service" modules (order-number-duplicate-link-service.ts, order-order-duplicate-link-service.ts) mirror the existing order-delivery-note-link-service.ts pattern exactly: pure visibility-only matchers that stamp denormalized fields onto the orders row, wired into createOrder's existing after() hook so a matcher failure never blocks order creation. The existing delivery-note matcher gets two surgical changes (drop its status allowlist, add a 60-day window) rather than a rewrite. A one-off backfill script re-runs all three matchers over every existing order so historical data gets flagged too, not just new intake.

Tech Stack: Next.js route handlers, PocketBase (admin SDK), Vitest. No new npm packages.


Prerequisite — PocketBase schema changes (manual, PB admin UI only)

Per this repo's convention (AGENTS.md), new PocketBase fields are not self-service from code — the running PB instance is the source of truth and fields must be added by hand in the PB admin UI before any code in this plan is deployed. Do this first, then tell the implementing agent the fields exist so it can proceed.

Go to PB admin → Collections → orders → add these 7 fields (all optional / not required):

Field name Type Options
order_number_duplicate Relation (single) Collection: orders
order_number_duplicate_checked Bool —
potential_duplicate_order Relation (single) Collection: orders
potential_duplicate_order_number Text —
duplicate_order_match_confidence Number —
duplicate_order_match_method Text —
duplicate_order_matching_attempted Bool —

These mirror the existing potential_delivery_note / potential_delivery_note_number / delivery_note_match_confidence / delivery_note_match_method / delivery_note_matching_attempted fields already on orders — same shape, same reasoning (denormalized number so the list pill never needs a second expand).

No delivery_notes schema changes are needed for Task 4 (it only changes a query filter, not what's stored).


File Structure

  • Create: src/lib/server/order-number-duplicate-link-service.ts — exact order_number match, any status, no time window.
  • Create: src/lib/server/order-order-duplicate-link-service.ts — exact line-item match, same company, any status, last 60 days.
  • Create: src/lib/server/order-number-duplicate-link-service.test.ts
  • Create: src/lib/server/order-order-duplicate-link-service.test.ts
  • Modify: src/lib/server/order-delivery-note-link-service.ts — drop the DN status allowlist, add a 60-day candidate window.
  • Modify: src/lib/server/order-delivery-note-link-service.test.ts (if it exists — check first; add cases either way).
  • Modify: src/lib/server/orders-service.ts — wire the two new matchers into createOrder's existing after() hook (~line 2264-2281).
  • Modify: src/lib/types.ts — add the 7 new fields to the Order interface.
  • Modify: src/app/(app)/orders/page.tsx — two new pills (order-number-duplicate, item-duplicate), next to the existing repeat-order (DN) pill at ~line 743.
  • Create: src/app/api/v1/orders/backfill-duplicate-checks/route.ts — POST endpoint that re-runs all three matchers over every existing order (operator-triggered, not automatic — this is what makes existing orders get flagged, since the matchers otherwise only run on new createOrder calls).
  • Create: src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts

Task 1: Order-number duplicate matcher

Files:

  • Create: src/lib/server/order-number-duplicate-link-service.ts

  • Test: src/lib/server/order-number-duplicate-link-service.test.ts

  • Step 1: Write the failing test

There is no shared PocketBase mock module in this repo — every service test file builds its own mock inline via a vi.hoisted block (see src/lib/server/orders-service.test.ts and src/lib/server/orders-service-review.test.ts). Copy that exact vi.hoisted mock-store block from the top of orders-service-review.test.ts verbatim into this new test file (it defines store, pbMock, clearStore, and mocks @/lib/server/pocketbase-admin's getAdminPocketBase to return pbMock) — do not invent a new mock shape. Below assumes that block is in place and exposes pbMock and clearStore:

// src/lib/server/order-number-duplicate-link-service.test.ts
import { describe, it, expect, beforeEach } from "vitest";
// ... paste the vi.hoisted({ store, pbMock, clearStore }) block and the
// vi.mock("@/lib/server/pocketbase-admin", ...) call from
// orders-service-review.test.ts here, unmodified ...
import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service";

beforeEach(() => {
  clearStore();
});

describe("suggestOrderNumberDuplicate", () => {
  it("flags two orders that share the same order_number, regardless of status", async () => {
    await pbMock.collection("orders").create({
      id: "order-a",
      order_number: "NO-REF",
      status: "delivered",
    });
    const orderB = await pbMock.collection("orders").create({
      id: "order-b",
      order_number: "NO-REF",
      status: "new",
    });

    const result = await suggestOrderNumberDuplicate(orderB.id);

    expect(result.duplicateOrderId).toBe("order-a");
    const updated = await pbMock.collection("orders").getOne("order-b");
    expect(updated.order_number_duplicate).toBe("order-a");
    expect(updated.order_number_duplicate_checked).toBe(true);
  });

  it("does not flag when order_number is unique", async () => {
    const order = await pbMock.collection("orders").create({
      id: "order-solo",
      order_number: "UNIQUE-123",
    });

    const result = await suggestOrderNumberDuplicate(order.id);

    expect(result.duplicateOrderId).toBeNull();
    const updated = await pbMock.collection("orders").getOne("order-solo");
    expect(updated.order_number_duplicate).toBeFalsy();
    expect(updated.order_number_duplicate_checked).toBe(true);
  });

  it("prefers the oldest other order when more than two orders share a number", async () => {
    await pbMock.collection("orders").create({
      id: "order-oldest",
      order_number: "SHARED",
      created: "2026-01-01 00:00:00.000Z",
    });
    await pbMock.collection("orders").create({
      id: "order-middle",
      order_number: "SHARED",
      created: "2026-02-01 00:00:00.000Z",
    });
    const orderNewest = await pbMock.collection("orders").create({
      id: "order-newest",
      order_number: "SHARED",
      created: "2026-03-01 00:00:00.000Z",
    });

    const result = await suggestOrderNumberDuplicate(orderNewest.id);

    // Points at the original, not just "any" other match — this is
    // what makes the pill useful (click through to the FIRST order,
    // not an arbitrary sibling).
    expect(result.duplicateOrderId).toBe("order-oldest");
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/order-number-duplicate-link-service.test.ts Expected: FAIL — Cannot find module './order-number-duplicate-link-service'

  • Step 3: Write minimal implementation
// src/lib/server/order-number-duplicate-link-service.ts
/**
 * order-number-duplicate-link-service — flags when a new (or
 * existing, via backfill) order shares its exact `order_number` with
 * another order, of ANY status, with no time limit.
 *
 * Visibility only, same reasoning as order-delivery-note-link-service:
 * a wrong flag costs nothing, the operator just ignores the pill. This
 * does NOT block creation (see orders-service.ts's
 * `looksLikeAutoOrderNumber` guard for the one case that DOES block —
 * a caller-supplied value that impersonates our own auto-generated
 * ORD-YYYY-MM-NNN sequence). Free-text customer PO references and
 * placeholders like "NO-REF" are expected to repeat (one blanket PO
 * split across several orders) — this matcher's job is purely to make
 * that repetition visible, not to prevent it.
 */

import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";

const ORDER_COLLECTION = "orders";

export interface OrderNumberDuplicateResult {
  orderId: string;
  duplicateOrderId: string | null;
}

function esc(s: string): string {
  return (s || "").replace(/"/g, '\\"');
}

export async function suggestOrderNumberDuplicate(
  orderId: string,
): Promise<OrderNumberDuplicateResult> {
  const pb = await getAdminPocketBase();
  pb.autoCancellation(false);

  const order = await pb
    .collection(ORDER_COLLECTION)
    .getOne<{ id: string; order_number?: string }>(orderId);

  if (!order.order_number) {
    return { orderId, duplicateOrderId: null };
  }

  const others = await pb
    .collection(ORDER_COLLECTION)
    .getFullList<{ id: string; created: string }>({
      filter: `order_number = "${esc(order.order_number)}" && id != "${esc(orderId)}"`,
      fields: "id,created",
      sort: "created",
      batch: 500,
    });

  const duplicateOrderId = others.length > 0 ? others[0].id : null;

  await pb.collection(ORDER_COLLECTION).update(orderId, {
    order_number_duplicate: duplicateOrderId,
    order_number_duplicate_checked: true,
  });

  return { orderId, duplicateOrderId };
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/order-number-duplicate-link-service.test.ts Expected: PASS (3 tests)

  • Step 5: Commit
git add src/lib/server/order-number-duplicate-link-service.ts src/lib/server/order-number-duplicate-link-service.test.ts
git commit -m "feat(orders): flag exact order_number duplicates, any status, no time limit"

Task 2: Order-vs-order item duplicate matcher (same company, last 60 days, any status)

Files:

  • Create: src/lib/server/order-order-duplicate-link-service.ts
  • Test: src/lib/server/order-order-duplicate-link-service.test.ts

Reuses the existing isExactItemMatch / DeliveryLine comparator from src/lib/server/delivery-note-order-match.ts — it's already a generic "two bags of {description, quantity} are an exact multiset match" comparator with no delivery-note-specific logic in it, despite the file name.

  • Step 1: Write the failing test

Same note as Task 1: copy the vi.hoisted({ store, pbMock, clearStore }) block and the vi.mock("@/lib/server/pocketbase-admin", ...) call verbatim from src/lib/server/orders-service-review.test.ts into this file — there is no shared mock module.

// src/lib/server/order-order-duplicate-link-service.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ... paste the vi.hoisted block + pocketbase-admin mock here, unmodified ...
import { suggestOrderDuplicate } from "./order-order-duplicate-link-service";

beforeEach(() => {
  clearStore();
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2026-08-30T12:00:00.000Z"));
});

afterEach(() => {
  vi.useRealTimers();
});

async function seedOrderWithItems(
  orderId: string,
  fields: Record<string, unknown>,
  items: Array<{ description: string; quantity: number }>,
) {
  await pbMock.collection("orders").create({ id: orderId, ...fields });
  for (let i = 0; i < items.length; i++) {
    await pbMock.collection("order_items").create({
      id: `${orderId}-item-${i}`,
      order: orderId,
      description: items[i].description,
      quantity: items[i].quantity,
    });
  }
}

describe("suggestOrderDuplicate", () => {
  it("flags an order whose items exactly match another order's, same company, within 60 days, regardless of status", async () => {
    await seedOrderWithItems(
      "order-original",
      {
        order_number: "ORD-2026-08-001",
        company: "comp-1",
        created: "2026-07-15 00:00:00.000Z", // 46 days before "now"
        status: "delivered", // status must not matter
      },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );
    await seedOrderWithItems(
      "order-repeat",
      {
        order_number: "ORD-2026-08-090",
        company: "comp-1",
        created: "2026-08-30 00:00:00.000Z",
        status: "new",
      },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );

    const result = await suggestOrderDuplicate("order-repeat");

    expect(result.duplicateOrderId).toBe("order-original");
    const updated = await pbMock.collection("orders").getOne("order-repeat");
    expect(updated.potential_duplicate_order).toBe("order-original");
    expect(updated.potential_duplicate_order_number).toBe("ORD-2026-08-001");
    expect(updated.duplicate_order_match_confidence).toBe(1);
    expect(updated.duplicate_order_match_method).toBe("exact_items_same_company");
    expect(updated.duplicate_order_matching_attempted).toBe(true);
  });

  it("does not flag when the matching order is for a different company", async () => {
    await seedOrderWithItems(
      "order-other-company",
      { order_number: "ORD-A", company: "comp-2", created: "2026-08-01 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );
    await seedOrderWithItems(
      "order-mine",
      { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );

    const result = await suggestOrderDuplicate("order-mine");

    expect(result.duplicateOrderId).toBeNull();
  });

  it("does not flag when the matching order is older than 60 days", async () => {
    await seedOrderWithItems(
      "order-too-old",
      { order_number: "ORD-A", company: "comp-1", created: "2026-06-01 00:00:00.000Z" }, // 90 days before "now"
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );
    await seedOrderWithItems(
      "order-mine",
      { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );

    const result = await suggestOrderDuplicate("order-mine");

    expect(result.duplicateOrderId).toBeNull();
  });

  it("does not flag when item quantities differ", async () => {
    await seedOrderWithItems(
      "order-a",
      { order_number: "ORD-A", company: "comp-1", created: "2026-08-01 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );
    await seedOrderWithItems(
      "order-b",
      { order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 250 }], // different qty
    );

    const result = await suggestOrderDuplicate("order-b");

    expect(result.duplicateOrderId).toBeNull();
  });

  it("never matches an order against itself", async () => {
    await seedOrderWithItems(
      "order-solo",
      { order_number: "ORD-A", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
      [{ description: "M16x50 Bolt", quantity: 300 }],
    );

    const result = await suggestOrderDuplicate("order-solo");

    expect(result.duplicateOrderId).toBeNull();
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/lib/server/order-order-duplicate-link-service.test.ts Expected: FAIL — Cannot find module './order-order-duplicate-link-service'

  • Step 3: Write minimal implementation
// src/lib/server/order-order-duplicate-link-service.ts
/**
 * order-order-duplicate-link-service — detects when a new order's
 * line items (description + quantity, exact multiset match) exactly
 * repeat another order for the SAME company, created within the last
 * 60 days, REGARDLESS of either order's status.
 *
 * Visibility only — same reasoning as order-delivery-note-link-service
 * and order-number-duplicate-link-service. This is deliberately a
 * separate flag/pill from the delivery-note repeat check: this one
 * catches "we already have an ORDER for this," the DN check catches
 * "we already DELIVERED this" — different facts, different pills.
 */

import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match";

const ORDER_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
const WINDOW_DAYS = 60;

export type OrderDuplicateMatchMethod = "exact_items_same_company" | "no_match";

export interface SuggestOrderDuplicateResult {
  orderId: string;
  duplicateOrderId: string | null;
  method: OrderDuplicateMatchMethod;
}

function esc(s: string): string {
  return (s || "").replace(/"/g, '\\"');
}

function pbDate(d: Date): string {
  return d.toISOString().replace("T", " ").replace("Z", "Z");
}

export async function suggestOrderDuplicate(
  orderId: string,
): Promise<SuggestOrderDuplicateResult> {
  const pb = await getAdminPocketBase();
  pb.autoCancellation(false);

  const order = await pb
    .collection(ORDER_COLLECTION)
    .getOne<{ id: string; company?: string; created: string }>(orderId);

  if (!order.company) {
    // No signal to match on — same "leave attempted unset" reasoning
    // as the delivery-note matcher: a company gets added later and a
    // future sweep should reconsider it.
    return { orderId, duplicateOrderId: null, method: "no_match" };
  }

  const orderItemRows = await pb
    .collection(ORDER_ITEMS_COLLECTION)
    .getFullList<{ description?: string; quantity?: number }>({
      filter: `order = "${esc(orderId)}"`,
      fields: "description,quantity",
      batch: 1000,
    });
  const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
    description: r.description || "",
    quantity: Number(r.quantity) || 0,
  }));

  const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);

  const candidates = await pb
    .collection(ORDER_COLLECTION)
    .getFullList<{ id: string; order_number: string; created: string }>({
      filter: `company = "${esc(order.company)}" && id != "${esc(orderId)}" && created >= "${pbDate(cutoff)}"`,
      fields: "id,order_number,created",
      sort: "created",
      batch: 500,
    });

  let match: { id: string; order_number: string } | null = null;
  for (const candidate of candidates) {
    const candidateItemRows = await pb
      .collection(ORDER_ITEMS_COLLECTION)
      .getFullList<{ description?: string; quantity?: number }>({
        filter: `order = "${esc(candidate.id)}"`,
        fields: "description,quantity",
        batch: 1000,
      });
    const candidateLines: DeliveryLine[] = candidateItemRows.map((r) => ({
      description: r.description || "",
      quantity: Number(r.quantity) || 0,
    }));
    if (isExactItemMatch(orderLines, candidateLines)) {
      match = candidate;
      break; // candidates sorted oldest-first — first hit is the original
    }
  }

  await pb.collection(ORDER_COLLECTION).update(orderId, {
    potential_duplicate_order: match ? match.id : null,
    potential_duplicate_order_number: match ? match.order_number : "",
    duplicate_order_match_confidence: match ? 1 : 0,
    duplicate_order_match_method: match ? "exact_items_same_company" : "no_match",
    duplicate_order_matching_attempted: true,
  });

  return {
    orderId,
    duplicateOrderId: match ? match.id : null,
    method: match ? "exact_items_same_company" : "no_match",
  };
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/lib/server/order-order-duplicate-link-service.test.ts Expected: PASS (5 tests)

  • Step 5: Commit
git add src/lib/server/order-order-duplicate-link-service.ts src/lib/server/order-order-duplicate-link-service.test.ts
git commit -m "feat(orders): flag exact item-match duplicates, same company, last 60 days"

Task 3: Widen the delivery-note matcher — any status, 60-day window

Files:

  • Modify: src/lib/server/order-delivery-note-link-service.ts:36-58 (the CANDIDATE_STATUSES const and the candidate query)

  • Test: whichever test file currently covers suggestDeliveryNoteForOrder — run grep -rln "suggestDeliveryNoteForOrder" src --include="*.test.ts" first to find it; add cases there.

  • Step 1: Write the failing tests

Add these two cases to the existing delivery-note-matcher test file (find it via the grep above):

it("matches a cancelled delivery note (status no longer filters candidates)", async () => {
  // Business requirement (2026-08-30): match against a DN of ANY
  // status, including cancelled — the earlier exclusion of cancelled
  // DNs missed real repeat orders where the original delivery was
  // marked cancelled for unrelated reasons (see CANDIDATE_STATUSES
  // history in this file).
  await pbMock.collection("delivery_notes").create({
    id: "dn-cancelled",
    delivery_note_number: "DN-CANC-1",
    company: "comp-1",
    status: "cancelled",
    created: "2026-08-01 00:00:00.000Z",
  });
  await pbMock.collection("order_items").create({
    id: "dn-item-1",
    delivery_note_number: "DN-CANC-1",
    description: "M16x50 Bolt",
    quantity: 300,
  });
  const order = await pbMock.collection("orders").create({
    id: "order-repeat",
    company: "comp-1",
  });
  await pbMock.collection("order_items").create({
    id: "order-item-1",
    order: "order-repeat",
    description: "M16x50 Bolt",
    quantity: 300,
  });

  const result = await suggestDeliveryNoteForOrder("order-repeat");

  expect(result.best?.deliveryNoteId).toBe("dn-cancelled");
});

it("does not match a delivery note older than 60 days", async () => {
  await pbMock.collection("delivery_notes").create({
    id: "dn-old",
    delivery_note_number: "DN-OLD-1",
    company: "comp-1",
    status: "completed",
    created: "2026-05-01 00:00:00.000Z", // > 60 days before "now"
  });
  await pbMock.collection("order_items").create({
    id: "dn-old-item-1",
    delivery_note_number: "DN-OLD-1",
    description: "M16x50 Bolt",
    quantity: 300,
  });
  const order = await pbMock.collection("orders").create({
    id: "order-fresh",
    company: "comp-1",
  });
  await pbMock.collection("order_items").create({
    id: "order-fresh-item-1",
    order: "order-fresh",
    description: "M16x50 Bolt",
    quantity: 300,
  });

  const result = await suggestDeliveryNoteForOrder("order-fresh");

  expect(result.best).toBeNull();
});

If the test file uses vi.useFakeTimers() elsewhere, reuse the same "now" it sets; otherwise add vi.setSystemTime(new Date("2026-08-30T12:00:00.000Z")) in a beforeEach for this file, matching Task 2's pattern.

  • Step 2: Run test to verify it fails

Run: npx vitest run <path-to-that-test-file> Expected: FAIL — cancelled-DN test fails (candidate excluded by CANDIDATE_STATUSES); 60-day test currently has no time filter at all, so it will incorrectly PASS today (no filter = matches everything) — note this in your PR description as "this test only becomes meaningful after Step 3."

  • Step 3: Modify order-delivery-note-link-service.ts

Replace the CANDIDATE_STATUSES block and the candidate query:

// DELETE the CANDIDATE_STATUSES const and its long comment block
// (lines ~36-58) entirely — per 2026-08-30 business decision, match
// against a DN of ANY status, including cancelled. The prior
// exclusion of cancelled DNs (84 of 116 live DNs at the time) missed
// real repeat orders where the original delivery's DN had been
// cancelled for reasons unrelated to whether goods went out.

Then in suggestDeliveryNoteForOrder, change the candidate query (currently ~line 116-122):

  const WINDOW_DAYS = 60;
  const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
  const cutoffStr = cutoff.toISOString().replace("T", " ").replace("Z", "Z");

  const candidates = await pb
    .collection(DELIVERY_NOTES_COLLECTION)
    .getFullList<DnCandidate>({
      filter: `company = "${esc(order.company)}" && created >= "${cutoffStr}"`,
      fields: "id,delivery_note_number,company,status,created,order",
      batch: 1000,
    });

(No status clause at all — any status qualifies now.)

  • Step 4: Run test to verify it passes

Run: npx vitest run <path-to-that-test-file> Expected: PASS, including both new cases and every pre-existing case in that file (re-run the full file, not just the new tests — confirm nothing that depended on CANDIDATE_STATUSES excluding cancelled broke).

  • Step 5: Commit
git add src/lib/server/order-delivery-note-link-service.ts <test-file-path>
git commit -m "feat(orders): widen DN repeat-match to any status, add 60-day window"

Task 4: Add the 7 new fields to the Order type

Files:

  • Modify: src/lib/types.ts:648 (the Order interface, right after the existing potential_delivery_note_number field at line 671)

  • Step 1: Add the fields

  /**
   * Relation to another order sharing the exact same `order_number`,
   * of any status, no time limit. Set by
   * suggestOrderNumberDuplicate (order-number-duplicate-link-service.ts).
   * Visibility only — free-text custom order numbers (customer PO
   * refs, "NO-REF" placeholders) are allowed to repeat by design; this
   * flag exists so the operator can eyeball whether a given repeat was
   * intentional.
   */
  order_number_duplicate?: string;
  /** True once suggestOrderNumberDuplicate has run for this order —
   *  distinguishes "checked, no match" from "never checked." */
  order_number_duplicate_checked?: boolean;
  /**
   * Relation to another order whose line items (description +
   * quantity) exactly match this order's, for the same company,
   * within the last 60 days, regardless of either order's status. Set
   * by suggestOrderDuplicate (order-order-duplicate-link-service.ts).
   * Visibility only.
   */
  potential_duplicate_order?: string;
  /** Denormalized copy of the matched order's order_number — same
   *  reasoning as potential_delivery_note_number: lets the list pill
   *  render without a second expand. */
  potential_duplicate_order_number?: string;
  /** 1 if potential_duplicate_order is set, 0 otherwise. */
  duplicate_order_match_confidence?: number;
  /** "exact_items_same_company" | "no_match" */
  duplicate_order_match_method?: string;
  /** True once suggestOrderDuplicate has run for this order. */
  duplicate_order_matching_attempted?: boolean;
  • Step 2: Typecheck

Run: npx tsc --noEmit -p tsconfig.json Expected: no new errors.

  • Step 3: Commit
git add src/lib/types.ts
git commit -m "feat(orders): add order/item duplicate-detection fields to Order type"

Task 5: Wire both new matchers into createOrder's after() hook

Files:

  • Modify: src/lib/server/orders-service.ts:3-4 (imports), :2264-2281 (the runQuoteMatch closure)

  • Step 1: Add imports

At the top of orders-service.ts, alongside the existing:

import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service";

add:

import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service";
import { suggestOrderDuplicate } from "./order-order-duplicate-link-service";
  • Step 2: Add two more independent try/catch blocks inside runQuoteMatch

Find the existing closure (~line 2264):

  const runQuoteMatch = async () => {
    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,
      );
    }
  };

Add two more blocks, same shape, same reasoning (each matcher failure must never fail order creation or block a sibling matcher):

  const runQuoteMatch = async () => {
    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,
      );
    }
    try {
      await suggestOrderNumberDuplicate(order.id);
    } catch (err) {
      console.error(
        `[createOrder] order-number duplicate check failed for order ${order.id}:`,
        err instanceof Error ? err.message : err,
      );
    }
    try {
      await suggestOrderDuplicate(order.id);
    } catch (err) {
      console.error(
        `[createOrder] order-duplicate item match failed for order ${order.id}:`,
        err instanceof Error ? err.message : err,
      );
    }
  };
  • Step 3: Run the full orders-service test suite

Run: npx vitest run src/lib/server/orders-service.test.ts Expected: PASS, same count as before this task (this change doesn't alter createOrder's return value or synchronous behavior — the new calls run inside the same fire-and-forget after() hook as the existing quote/DN matchers, which the existing tests already tolerate).

  • Step 4: Commit
git add src/lib/server/orders-service.ts
git commit -m "feat(orders): run order-number and item-duplicate checks on every new order"

Task 6: Add the two new pills to /orders

Files:

  • Modify: src/app/(app)/orders/page.tsx:736-757 (right after the existing repeat-order/DN pill)

  • Step 1: Add the pills

Right after the existing block (lines 736-757, the order.potential_delivery_note_number && pill), add:

              {/* Order-number-duplicate pill — set by
                  suggestOrderNumberDuplicate when another order (any
                  status, no time limit) shares this exact order_number.
                  Red — this is the one most likely to indicate an
                  actual accident (a repeated internal ORD- number, or
                  an operator not realizing a PO was already entered),
                  as opposed to the item-duplicate pill below which is
                  routinely legitimate (split PO deliveries). */}
              {order.order_number_duplicate && (
                <span
                  title="Another order already uses this exact order number"
                  className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
                  style={{
                    background: "rgba(239, 68, 68, 0.10)",
                    color: "#ef4444",
                    boxShadow: "inset 0 0 0 1px rgba(239, 68, 68, 0.30)",
                  }}
                  aria-label="Duplicate order number"
                >
                  Dup #
                </span>
              )}
              {/* Item-duplicate pill — set by suggestOrderDuplicate when
                  this order's line items exactly match another order
                  for the same company within the last 60 days,
                  regardless of status. Amber, distinct from the DN
                  pill's blue (a repeat ORDER, not a repeat DELIVERY)
                  and from the order-number pill's red (this one is
                  routinely legitimate — a customer re-ordering the
                  same basket — not necessarily a mistake). */}
              {order.potential_duplicate_order_number && (
                <span
                  title={`Same items as order ${order.potential_duplicate_order_number} (same company, last 60 days)`}
                  className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
                  style={{
                    background: "rgba(245, 158, 11, 0.10)",
                    color: "#f59e0b",
                    boxShadow: "inset 0 0 0 1px rgba(245, 158, 11, 0.30)",
                  }}
                  aria-label={`Duplicate of order ${order.potential_duplicate_order_number}`}
                >
                  Dup of {order.potential_duplicate_order_number}
                </span>
              )}
  • Step 2: Manual check

Since this is a UI change, start the dev server and confirm the pills render on an order seeded with order_number_duplicate / potential_duplicate_order_number set (use the PB admin UI to hand-set these on two test orders, or wait for Task 7's backfill to populate them for real).

  • Step 3: Commit
git add "src/app/(app)/orders/page.tsx"
git commit -m "feat(orders): render order-number and item-duplicate pills"

Task 7: Backfill endpoint — flag existing orders, not just new ones

The three matchers above only run inside createOrder, so every order created before this ships stays unflagged forever unless something re-runs the matchers against history. This is the piece that answers "if your order is already there" for orders that already exist today.

Files:

  • Create: src/app/api/v1/orders/backfill-duplicate-checks/route.ts

  • Create: src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts

  • Step 1: Write the failing test

There is no shared "authed request" test helper in this repo — route tests build a plain NextRequest directly and rely on AGENT_API_KEY being set in the test env (see assertAgentAuth in src/lib/server/agent-auth.ts, and how src/app/api/v1/orders/[id]/review-save/route.test.ts constructs its request with new NextRequest(...)). Follow that exact pattern:

// src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "./route";

vi.mock("@/lib/server/order-number-duplicate-link-service", () => ({
  suggestOrderNumberDuplicate: vi.fn().mockResolvedValue({ duplicateOrderId: null }),
}));
vi.mock("@/lib/server/order-order-duplicate-link-service", () => ({
  suggestOrderDuplicate: vi.fn().mockResolvedValue({ duplicateOrderId: null, method: "no_match" }),
}));
vi.mock("@/lib/server/order-delivery-note-link-service", () => ({
  suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({ best: null, method: "no_match" }),
}));
vi.mock("@/lib/server/pocketbase-admin", () => ({
  getAdminPocketBase: vi.fn().mockResolvedValue({
    autoCancellation: vi.fn(),
    collection: () => ({
      getFullList: vi.fn().mockResolvedValue([{ id: "order-1" }, { id: "order-2" }]),
    }),
  }),
}));

const AGENT_API_KEY = "test-agent-key";

function backfillRequest(): NextRequest {
  return new NextRequest("http://localhost/api/v1/orders/backfill-duplicate-checks", {
    method: "POST",
    headers: { authorization: `Bearer ${AGENT_API_KEY}` },
  });
}

describe("POST /api/v1/orders/backfill-duplicate-checks", () => {
  beforeEach(() => {
    process.env.AGENT_API_KEY = AGENT_API_KEY;
  });

  it("runs all three matchers for every order and reports counts", async () => {
    const res = await POST(backfillRequest());
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.ordersProcessed).toBe(2);
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts Expected: FAIL — route module doesn't exist yet.

  • Step 3: Write the route
// src/app/api/v1/orders/backfill-duplicate-checks/route.ts
/**
 * Operator-triggered backfill: re-runs all three duplicate-detection
 * matchers (order-number, item-duplicate, delivery-note-repeat) over
 * EVERY existing order. The matchers themselves only run automatically
 * on new order creation (see orders-service.ts createOrder) — this
 * endpoint is what gets historical orders flagged too.
 *
 * Intentionally sequential per order (not Promise.all) — this can run
 * over the full orders table, and PocketBase's admin client doesn't
 * love thousands of concurrent requests. This is a background/admin
 * operation, not a user-facing latency path.
 */
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { suggestOrderNumberDuplicate } from "@/lib/server/order-number-duplicate-link-service";
import { suggestOrderDuplicate } from "@/lib/server/order-order-duplicate-link-service";
import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service";
import { assertAgentAuth } from "@/lib/server/agent-auth";

export async function POST(req: NextRequest) {
  try {
    assertAgentAuth(req);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unauthorized";
    return NextResponse.json({ error: message }, { status: 401 });
  }

  const pb = await getAdminPocketBase();
  pb.autoCancellation(false);

  const orders = await pb
    .collection("orders")
    .getFullList<{ id: string }>({ fields: "id", batch: 500 });

  let ordersProcessed = 0;
  const failures: Array<{ orderId: string; error: string }> = [];

  for (const order of orders) {
    try {
      await suggestOrderNumberDuplicate(order.id);
    } catch (err) {
      failures.push({ orderId: order.id, error: `order_number: ${err instanceof Error ? err.message : String(err)}` });
    }
    try {
      await suggestOrderDuplicate(order.id);
    } catch (err) {
      failures.push({ orderId: order.id, error: `item_duplicate: ${err instanceof Error ? err.message : String(err)}` });
    }
    try {
      await suggestDeliveryNoteForOrder(order.id);
    } catch (err) {
      failures.push({ orderId: order.id, error: `delivery_note: ${err instanceof Error ? err.message : String(err)}` });
    }
    ordersProcessed++;
  }

  return NextResponse.json({ ordersProcessed, failures });
}

Check requireAgentAuth against how a real neighboring route (e.g. src/app/api/v1/orders/create/route.ts) actually guards auth — copy that exact pattern instead of guessing the helper name.

  • Step 4: Run test to verify it passes

Run: npx vitest run src/app/api/v1/orders/backfill-duplicate-checks/route.test.ts Expected: PASS

  • Step 5: Commit
git add src/app/api/v1/orders/backfill-duplicate-checks
git commit -m "feat(orders): add backfill endpoint for duplicate-detection fields"
  • Step 6: Run it once against the live data after deploy

Not a code step — after this ships, call the endpoint once (e.g. via curl with the agent API key, or a one-off MCP/agent call) so every existing order gets checked. Expect this to take a while on ~4,800 orders; consider running it during low traffic.


Self-Review

Spec coverage:

  1. "order number exactly the same must flag, regardless of status" → Task 1 (any status, no time filter, confirmed by test "flags two orders that share the same order_number, regardless of status").
  2. "order items ... exactly the same ... regardless of status ... same company ... last 90 days or 60 days" → Task 2, using 60 days per the user's stated preference ("60 days is even better"), any status (no status filter in the query at all), same company (company = "..." in the filter).
  3. "delivery note regardless of status ... within last 60 days ... match of order items ... flag it ... pill" → Task 3 (drops the status allowlist entirely, adds the 60-day window) — the pill itself already exists (potential_delivery_note_number pill in orders/page.tsx), unchanged by this plan.
  4. "if your order is already there" (existing historical orders, not just new ones) → Task 7's backfill endpoint.

Placeholder scan: every step has real, complete code — no "TBD"/"add validation" left in. The two spots that say "adjust to this repo's actual X" (test mock helper path, auth helper name) are explicitly flagged as things to grep for and copy verbatim from a neighboring file, not guesses left unresolved — the implementing agent should resolve those to a concrete import in Step 1/3 of Task 7 before considering it done.

Type consistency: suggestOrderNumberDuplicate(orderId): Promise<OrderNumberDuplicateResult> (Task 1) and suggestOrderDuplicate(orderId): Promise<SuggestOrderDuplicateResult> (Task 2) signatures are used identically in Task 5's wiring and Task 7's backfill route. Field names (order_number_duplicate, order_number_duplicate_checked, potential_duplicate_order, potential_duplicate_order_number, duplicate_order_match_confidence, duplicate_order_match_method, duplicate_order_matching_attempted) match exactly across the PB schema prerequisite, Task 4's type additions, Tasks 1-2's writes, and Task 6's reads.