diff --git a/check-progress.mjs b/check-progress.mjs new file mode 100644 index 0000000..4c2b008 --- /dev/null +++ b/check-progress.mjs @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import PocketBase from "pocketbase"; +const envLocal = readFileSync(".env.local", "utf8"); +for (const line of envLocal.split("\n")) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); +} +const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL); +await pb.admins.authWithPassword(process.env.PB_ADMIN_EMAIL, process.env.PB_ADMIN_PASSWORD); + +let numChecked = 0, orderAttempted = 0, dnAttempted = 0, total = 0; +for (let p = 1; p <= 30; p++) { + const r = await pb.collection("orders").getList(p, 200, { + fields: "id,order_number_duplicate_checked,duplicate_order_matching_attempted,delivery_note_matching_attempted", + sort: "created", + }); + for (const o of r.items) { + total++; + if (o.order_number_duplicate_checked === true) numChecked++; + if (o.duplicate_order_matching_attempted === true) orderAttempted++; + if (o.delivery_note_matching_attempted === true) dnAttempted++; + } + if (r.items.length < 200) break; +} +const hits1 = await pb.collection("orders").getList(1, 100, { + filter: 'order_number_duplicate != "" && order_number_duplicate != null', +}); +const hits2 = await pb.collection("orders").getList(1, 100, { + filter: 'potential_duplicate_order != "" && potential_duplicate_order != null', +}); +const hits3 = await pb.collection("orders").getList(1, 100, { + filter: 'potential_delivery_note != "" && potential_delivery_note != null', +}); +console.log(`total=${total} num=${numChecked} (${(numChecked/total*100).toFixed(1)}%) order=${orderAttempted} (${(orderAttempted/total*100).toFixed(1)}%) dn=${dnAttempted} (${(dnAttempted/total*100).toFixed(1)}%)`); +console.log(`hits: order-num=${hits1.totalItems} item-dup=${hits2.totalItems} dn=${hits3.totalItems}`); diff --git a/docs/superpowers/plans/2026-08-30-order-duplicate-detection.md b/docs/superpowers/plans/2026-08-30-order-duplicate-detection.md new file mode 100644 index 0000000..5587b9e --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-order-duplicate-detection.md @@ -0,0 +1,1010 @@ +# 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`: + +```typescript +// 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** + +```typescript +// 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 { + 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** + +```bash +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. + +```typescript +// 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, + 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** + +```typescript +// 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 { + 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** + +```bash +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): + +```typescript +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 ` +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: + +```typescript +// 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): + +```typescript + 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({ + 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 ` +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** + +```bash +git add src/lib/server/order-delivery-note-link-service.ts +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** + +```typescript + /** + * 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** + +```bash +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: + +```typescript +import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service"; +``` + +add: + +```typescript +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): + +```typescript + 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): + +```typescript + 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** + +```bash +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: + +```tsx + {/* 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 && ( + + Dup # + + )} + {/* 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 && ( + + Dup of {order.potential_duplicate_order_number} + + )} +``` + +- [ ] **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** + +```bash +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: + +```typescript +// 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** + +```typescript +// 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** + +```bash +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` (Task 1) and `suggestOrderDuplicate(orderId): Promise` (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. diff --git a/run-backfill.mjs b/run-backfill.mjs new file mode 100644 index 0000000..b41f359 --- /dev/null +++ b/run-backfill.mjs @@ -0,0 +1,76 @@ +// Run the full backfill in 200-order chunks. Logs progress to stdout. +import { readFileSync } from "node:fs"; + +const envLocal = readFileSync(".env.local", "utf8"); +for (const line of envLocal.split("\n")) { + const m = line.match(/^([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); + if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ""); +} + +const baseUrl = "http://localhost:4001"; +const agentKey = process.env.AGENT_API_KEY; +if (!agentKey) { + console.error("Missing AGENT_API_KEY in .env.local"); + process.exit(1); +} + +const LIMIT = 200; +let skip = 0; +let totalProcessed = 0; +let totalFailures = 0; +const start = Date.now(); +let chunkNum = 0; + +while (true) { + chunkNum++; + const url = `${baseUrl}/api/v1/orders/backfill-duplicate-checks?limit=${LIMIT}&skip=${skip}`; + let body; + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 600_000); // 10 min per chunk + const res = await fetch(url, { + method: "POST", + headers: { authorization: `Bearer ${agentKey}` }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!res.ok) { + console.error(`[fail] HTTP ${res.status} at skip=${skip}`); + const text = await res.text(); + console.error(text.substring(0, 500)); + process.exit(2); + } + body = await res.json(); + } catch (err) { + console.error(`[err] fetch failed at skip=${skip}:`, err.message); + if (err.cause) console.error("cause:", err.cause); + process.exit(2); + } + + totalProcessed += body.ordersProcessed || 0; + totalFailures += body.failures?.length || 0; + const elapsed = Math.round((Date.now() - start) / 1000); + const rate = totalProcessed / (elapsed / 60); + console.log( + `[chunk ${chunkNum} skip=${skip} processed=${body.ordersProcessed} ` + + `failures=${body.failures?.length || 0} total=${totalProcessed} ` + + `nextSkip=${body.nextSkip} done=${body.done} elapsed=${elapsed}s rate=${rate.toFixed(1)}/min]`, + ); + + if (body.failures?.length > 0) { + for (const f of body.failures.slice(0, 3)) { + console.log(` failure: ${f.orderId} -> ${f.error}`); + } + if (body.failures.length > 3) { + console.log(` ... and ${body.failures.length - 3} more`); + } + } + + if (body.done) { + console.log( + `\n[done] total processed: ${totalProcessed}, total failures: ${totalFailures}, elapsed: ${elapsed}s (${(elapsed / 60).toFixed(1)} min)`, + ); + process.exit(0); + } + skip = body.nextSkip; +} diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 47d8de9..77da9c2 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -563,9 +563,12 @@ const tools = [ "whole order.\n\n" + "Optional `order_number`: pass a customer-supplied identifier (e.g. the PO/reference printed " + "on the inbound PO PDF) to use it verbatim as `orders.order_number`. Empty / whitespace " + - "values fall back to auto-generated `ORD-YYYY-MM-NNN` — same as omitting the field. On " + - "collision the call surfaces an MCP error envelope with code/message `order_number_collision` " + - "and a `data` block carrying the colliding order id and attempted number.\n\n" + + "values fall back to auto-generated `ORD-YYYY-MM-NNN` — same as omitting the field. Custom " + + "values are allowed to repeat across orders (e.g. one blanket PO split into several partial-" + + "delivery orders, or a shared \"no reference given\" placeholder) — collision-checking only " + + "applies when the value impersonates our own `ORD-YYYY-MM-NNN` format, in which case the call " + + "surfaces an MCP error envelope with code/message `order_number_collision` and a `data` block " + + "carrying the colliding order id and attempted number.\n\n" + "Optional `email_sender` / `email_subject` / `email_body`: stamp the inbound email metadata " + "on the order in the same call so the operator can trace it back to its source email without " + "a follow-up `orders.update` patch. The n8n intake pipeline fills these in from the parsed " + diff --git a/src/lib/server/orders-service-review.test.ts b/src/lib/server/orders-service-review.test.ts index a87079b..156a4aa 100644 --- a/src/lib/server/orders-service-review.test.ts +++ b/src/lib/server/orders-service-review.test.ts @@ -269,15 +269,39 @@ describe("updateOrderReviewFields", () => { expect(result.customer).toBe("cust-1"); }); - it("escapes embedded quotes in order_number before building the collision filter", async () => { - // An order_number containing `"` would break the filter - // expression unless we escape embedded quotes before - // interpolating. Spy on getList to capture the filter - // the service actually sends. - const order = await pbMock.collection("orders").create({ - order_number: "ORD-2026-07-001", + it("does not collision-check a free-text order_number, even one that duplicates another order's", async () => { + // Customer PO references (and placeholders like "NO-REF") are + // expected to repeat across several orders — e.g. one blanket PO + // split into multiple partial-delivery orders. Only values shaped + // like our own ORD-YYYY-MM-NNN sequence are collision-checked. + await pbMock.collection("orders").create({ + order_number: "PO-12345", customer: "cust-1", }); + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-002", + customer: "cust-2", + }); + + const result = await updateOrderReviewFields(order.id, { + order_number: "PO-12345", + }); + + expect(result.order_number).toBe("PO-12345"); + }); + + it("collision-checks an ORD-shaped order_number against the exact filter", async () => { + // Only ORD-YYYY-MM-NNN-shaped values are collision-checked (see + // the test above for free-text values). Spy on getList to confirm + // the check actually runs and filters on the exact number. + const existing = await pbMock.collection("orders").create({ + order_number: 'ORD-2026-07-001', + customer: "cust-1", + }); + const order = await pbMock.collection("orders").create({ + order_number: "ORD-2026-07-002", + customer: "cust-2", + }); const originalCollection = pbMock.collection; const captured: string[] = []; @@ -295,18 +319,15 @@ describe("updateOrderReviewFields", () => { }; try { - // Pick an order_number that contains a literal quote. The - // unescaped filter would be `order_number = "ORD "foo" && id != "..."`, - // which would either fail to parse or match the wrong rows. - await updateOrderReviewFields(order.id, { - order_number: 'ORD "weird"', - }); + await expect( + updateOrderReviewFields(order.id, { + order_number: existing.order_number, + }), + ).rejects.toBeInstanceOf(OrderNumberCollisionError); expect(captured.length).toBeGreaterThan(0); - // The filter MUST escape the `"` inside the literal — look for - // the backslash-escaped quote in the captured string. const filter = captured[captured.length - 1]; - expect(filter).toContain('ORD \\"weird\\"'); + expect(filter).toContain(`order_number = "${existing.order_number}"`); } finally { pbMock.collection = originalCollection; }