982 lines
39 KiB
Markdown
982 lines
39 KiB
Markdown
|
|
# Order ↔ Delivery-Note Repeat-Order Detection Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** When a new order's line items exactly match a past delivery note's items (same company), flag it automatically with a visible pill on the orders list showing the delivery note number.
|
|||
|
|
|
|||
|
|
**Architecture:** A pure exact-match comparator (no I/O) feeds a PB-backed service, `suggestDeliveryNoteForOrder`, which piggybacks on the `after()` hook already built and reviewed for the order↔quote matching feature — no new `after()` call, no new error-guard code to write or review. Two independent `try/catch` blocks so a failure in one matcher can't block the other. The match is written automatically (no human confirmation step) because it's informational only — nothing downstream reads it to drive pricing or processing.
|
|||
|
|
|
|||
|
|
**Tech Stack:** Next.js 16.2.6 (App Router), TypeScript, PocketBase (via `getAdminPocketBase()`), Vitest, Tailwind + shadcn/ui, lucide-react.
|
|||
|
|
|
|||
|
|
**Spec:** `docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md` — read it first. It records why the pill reads a denormalized text field instead of an expanded relation (a deliberate perf tradeoff already made once in this codebase for the quote-suggestion pill, at `orders-service.ts:253`) — do not "simplify" this by switching to an `expand`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Pre-flight — verify before writing code, do not assume
|
|||
|
|
|
|||
|
|
1. **This plan depends on `docs/superpowers/plans/2026-08-29-order-quote-linking.md` Task 6 being merged first** — specifically the `runQuoteMatch` closure inside `createOrder`'s `after()` block in `src/lib/server/orders-service.ts`. Confirm it exists (`grep -n "async function runQuoteMatch" src/lib/server/orders-service.ts`) before starting Task 4 below. If it isn't there yet, stop and report — do not build a second `after()` call as a workaround.
|
|||
|
|
2. **Confirm `getOrdersDashboardData`'s field selection.** Read `src/lib/server/orders-service.ts` around line 253 (the `potential_quote` expand-drop comment) and trace what the function actually returns to `/api/v1/orders/list` — full records or a `fields`-limited projection. The spec assumes full records (per the PB-0.26-drops-expand-when-fields-is-set note in the same comment block) so no allowlist change is needed for the new field to appear. If that assumption is wrong, Task 6 below needs an additional edit — say so in that task's report.
|
|||
|
|
3. **Confirm the migration script auth pattern still works.** `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` is the template for Task 1. Read it in full before writing the new one.
|
|||
|
|
|
|||
|
|
## File structure
|
|||
|
|
|
|||
|
|
| File | Responsibility |
|
|||
|
|
|---|---|
|
|||
|
|
| `src/lib/server/delivery-note-order-match.ts` *(create)* | Pure exact-match comparator. No I/O. |
|
|||
|
|
| `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts` *(create)* | Adds the 5 new `orders` fields. |
|
|||
|
|
| `src/lib/server/order-delivery-note-link-service.ts` *(create)* | PB-backed `suggestDeliveryNoteForOrder`. |
|
|||
|
|
| `src/lib/server/orders-service.ts` *(modify)* | Add the second matcher call inside the existing `runQuoteMatch` closure. |
|
|||
|
|
| `src/app/api/v1/orders/[id]/delivery-note-match/route.ts` *(create)* | On-demand single-order re-check. |
|
|||
|
|
| `src/lib/types.ts` *(modify)* | Add the 5 new fields to the `Order` interface. |
|
|||
|
|
| `src/app/(app)/orders/page.tsx` *(modify)* | New pill in the existing badge row. |
|
|||
|
|
|
|||
|
|
## Phase boundaries
|
|||
|
|
|
|||
|
|
- **Phase 1 — Tasks 1–4.** Backend: schema, matcher, service, intake wiring. Independently testable and mergeable without the UI.
|
|||
|
|
- **Phase 2 — Tasks 5–6.** On-demand route + UI pill.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# PHASE 1 — Backend
|
|||
|
|
|
|||
|
|
## Task 1: Migration script for the new `orders` fields
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the script**
|
|||
|
|
|
|||
|
|
Model this exactly on `scripts/migrations/2026-06-27-add-order-quote-match-fields.ts` — same env-loading, same auth pattern, same idempotent field-existence check per field, same re-fetch-after-each-write pattern (PB's `collections.update` needs the freshest `fields` array or it clobbers concurrent additions).
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
/* eslint-disable */
|
|||
|
|
/**
|
|||
|
|
* Migration: add delivery-note repeat-order detection fields to `orders`.
|
|||
|
|
*
|
|||
|
|
* Adds five fields:
|
|||
|
|
*
|
|||
|
|
* 1. `potential_delivery_note` (relation → delivery_notes, single)
|
|||
|
|
* The DN whose items exactly matched this order's items.
|
|||
|
|
*
|
|||
|
|
* 2. `potential_delivery_note_number` (text)
|
|||
|
|
* Denormalized copy of the matched DN's `delivery_note_number`.
|
|||
|
|
* Written alongside (1) so the orders list can render the number
|
|||
|
|
* without a second `expand` — see the comment at
|
|||
|
|
* orders-service.ts:253 for why that expand cost is avoided here
|
|||
|
|
* (PB issues a separate IN query per expanded relation).
|
|||
|
|
* `delivery_note_number` is immutable once assigned, so copying
|
|||
|
|
* it carries no staleness risk.
|
|||
|
|
*
|
|||
|
|
* 3. `delivery_note_match_confidence` (number)
|
|||
|
|
* Always 1 when set — the match is exact-or-nothing, not scored.
|
|||
|
|
* Kept for shape parity with quote_match_confidence and so a
|
|||
|
|
* future fuzzy extension doesn't need a second migration.
|
|||
|
|
*
|
|||
|
|
* 4. `delivery_note_match_method` (text)
|
|||
|
|
* "exact_items" or "no_match". Text, not select, for the same
|
|||
|
|
* forward-compatibility reason quote_match_method is text.
|
|||
|
|
*
|
|||
|
|
* 5. `delivery_note_matching_attempted` (bool)
|
|||
|
|
* Audit flag mirroring quote_matching_attempted.
|
|||
|
|
*
|
|||
|
|
* Idempotent — safe to re-run. Each step detects its own "already
|
|||
|
|
* done" state.
|
|||
|
|
*
|
|||
|
|
* Run with:
|
|||
|
|
* npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
|
|||
|
|
*/
|
|||
|
|
import PocketBase from "pocketbase";
|
|||
|
|
import { readFileSync } from "node:fs";
|
|||
|
|
import { resolve } from "node:path";
|
|||
|
|
|
|||
|
|
function loadEnvFile(name: string) {
|
|||
|
|
try {
|
|||
|
|
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
|||
|
|
for (const line of raw.split("\n")) {
|
|||
|
|
const trimmed = line.trim();
|
|||
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|||
|
|
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
|||
|
|
if (m && !process.env[m[1]]) {
|
|||
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// file not present — fine, rely on shell env
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
loadEnvFile(".env");
|
|||
|
|
loadEnvFile(".env.local");
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
|||
|
|
const email = process.env.PB_ADMIN_EMAIL;
|
|||
|
|
const password = process.env.PB_ADMIN_PASSWORD;
|
|||
|
|
if (!url || !email || !password) {
|
|||
|
|
throw new Error(
|
|||
|
|
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local)."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pb = new PocketBase(url);
|
|||
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|||
|
|
console.log(`[order-delivery-note-match] Authenticated against ${url}`);
|
|||
|
|
|
|||
|
|
const deliveryNotes = await pb.collections.getOne("delivery_notes");
|
|||
|
|
|
|||
|
|
async function addFieldIfMissing(
|
|||
|
|
fieldDef: Record<string, unknown> & { name: string },
|
|||
|
|
) {
|
|||
|
|
const orders = await pb.collections.getOne("orders");
|
|||
|
|
if (orders.fields.some((f: { name: string }) => f.name === fieldDef.name)) {
|
|||
|
|
console.log(`[order-delivery-note-match] orders.${fieldDef.name} already present — skipping.`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await pb.collections.update(orders.id, {
|
|||
|
|
fields: [...orders.fields, fieldDef],
|
|||
|
|
});
|
|||
|
|
console.log(`[order-delivery-note-match] Added orders.${fieldDef.name}.`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await addFieldIfMissing({
|
|||
|
|
name: "potential_delivery_note",
|
|||
|
|
type: "relation",
|
|||
|
|
required: false,
|
|||
|
|
presentable: false,
|
|||
|
|
options: {
|
|||
|
|
collectionId: deliveryNotes.id,
|
|||
|
|
cascadeDelete: false,
|
|||
|
|
minSelect: 0,
|
|||
|
|
maxSelect: 1,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await addFieldIfMissing({
|
|||
|
|
name: "potential_delivery_note_number",
|
|||
|
|
type: "text",
|
|||
|
|
required: false,
|
|||
|
|
presentable: false,
|
|||
|
|
options: { max: 32 },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await addFieldIfMissing({
|
|||
|
|
name: "delivery_note_match_confidence",
|
|||
|
|
type: "number",
|
|||
|
|
required: false,
|
|||
|
|
presentable: false,
|
|||
|
|
options: { min: 0, max: 1 },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await addFieldIfMissing({
|
|||
|
|
name: "delivery_note_match_method",
|
|||
|
|
type: "text",
|
|||
|
|
required: false,
|
|||
|
|
presentable: false,
|
|||
|
|
options: { max: 32 },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await addFieldIfMissing({
|
|||
|
|
name: "delivery_note_matching_attempted",
|
|||
|
|
type: "bool",
|
|||
|
|
required: false,
|
|||
|
|
presentable: false,
|
|||
|
|
options: {},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log("[order-delivery-note-match] Migration complete.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch((err) => {
|
|||
|
|
console.error("[order-delivery-note-match] Migration failed:", err);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**Step 2: Verify the relation field's `options` shape against a real PB response.**
|
|||
|
|
The `relation` field's `options` object (`collectionId`, `cascadeDelete`, `minSelect`, `maxSelect`) is written from memory of PocketBase's schema shape — before running this against production, fetch the *existing* `orders.quote` or `orders.potential_quote` field definition (`pb.collections.getOne("orders")`, inspect `.fields`) and confirm the option keys match exactly. If they differ, use the real shape, not what's written above.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Run it against the actual PB instance** (ask the user for confirmation before running any migration against a real database — this is a schema change, not a reversible local operation):
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: five "Added orders.X" lines, or "already present" on re-run.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Add the npm script**, matching the existing `pb:migrate:*` convention in `package.json`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
"pb:migrate:order-delivery-note-match": "npx tsx scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts",
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add scripts/migrations/2026-08-29-add-order-delivery-note-match-fields.ts package.json
|
|||
|
|
git commit -m "feat(orders): add delivery-note repeat-order match fields"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: Pure exact-match comparator
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `src/lib/server/delivery-note-order-match.ts`
|
|||
|
|
- Test: `src/lib/server/delivery-note-order-match.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the failing tests**
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
import { describe, it, expect } from "vitest";
|
|||
|
|
import { normalizeLine, isExactItemMatch } from "@/lib/server/delivery-note-order-match";
|
|||
|
|
|
|||
|
|
describe("normalizeLine", () => {
|
|||
|
|
it("trims, lowercases, and collapses internal whitespace", () => {
|
|||
|
|
expect(normalizeLine(" Hex Bolt M20 ")).toBe("hex bolt m20");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("isExactItemMatch", () => {
|
|||
|
|
it("matches when every line pairs 1:1 on normalized description and quantity", () => {
|
|||
|
|
const order = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Hex Nut M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
const dn = [
|
|||
|
|
{ description: "hex bolt m20", quantity: 50 },
|
|||
|
|
{ description: "HEX NUT M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("does not match when a quantity differs", () => {
|
|||
|
|
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
|
|||
|
|
const dn = [{ description: "Hex Bolt M20", quantity: 45 }];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("does not match when the order has an extra line the DN doesn't have", () => {
|
|||
|
|
const order = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Washer M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("does not match when the DN has an extra line the order doesn't have", () => {
|
|||
|
|
const order = [{ description: "Hex Bolt M20", quantity: 50 }];
|
|||
|
|
const dn = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Washer M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("requires matching multiplicity, not just membership, for duplicate lines", () => {
|
|||
|
|
// Order has the SAME line twice (two separate 50-unit lines of the
|
|||
|
|
// same bolt, e.g. from two different quote lines that priced
|
|||
|
|
// differently but described identically). The DN has only one.
|
|||
|
|
const order = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
const dn = [{ description: "Hex Bolt M20", quantity: 50 }];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("matches duplicate lines when both sides have the same multiplicity", () => {
|
|||
|
|
const order = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
const dn = [
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
{ description: "Hex Bolt M20", quantity: 50 },
|
|||
|
|
];
|
|||
|
|
expect(isExactItemMatch(order, dn)).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns false for two empty line sets rather than vacuously true", () => {
|
|||
|
|
// An order or DN with zero lines carries no signal — this must
|
|||
|
|
// never be treated as "everything matched." If this returned true,
|
|||
|
|
// an order with 0 lines could match every DN with 0 lines in the
|
|||
|
|
// company, which is meaningless.
|
|||
|
|
expect(isExactItemMatch([], [])).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run to verify red.** `npx vitest run src/lib/server/delivery-note-order-match.test.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Implementation**
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
/**
|
|||
|
|
* delivery-note-order-match — pure exact-match comparator for the
|
|||
|
|
* order ↔ delivery-note repeat-order detector.
|
|||
|
|
*
|
|||
|
|
* Deliberately NOT the quote matcher's tokenize/Jaccard fuzzy scoring
|
|||
|
|
* (order-quote-match.ts). This is exact-string equality on a
|
|||
|
|
* normalized form, with multiset (bag) semantics — every order line
|
|||
|
|
* must pair to exactly one DN line and nothing may be left over on
|
|||
|
|
* either side. There is no partial-credit tier here; either the order
|
|||
|
|
* is a complete repeat of the DN or it isn't flagged at all.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
export interface DeliveryLine {
|
|||
|
|
description: string;
|
|||
|
|
quantity: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Normalize for comparison: trim, lowercase, collapse internal
|
|||
|
|
* whitespace runs to a single space. */
|
|||
|
|
export function normalizeLine(s: string): string {
|
|||
|
|
return (s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* True iff `orderLines` and `dnLines` are equal as multisets keyed on
|
|||
|
|
* (normalized description, quantity). Empty input on either side
|
|||
|
|
* always returns false — there is no signal to match on, and treating
|
|||
|
|
* two empty sets as "matching" would flag every zero-line order
|
|||
|
|
* against every zero-line DN.
|
|||
|
|
*/
|
|||
|
|
export function isExactItemMatch(
|
|||
|
|
orderLines: ReadonlyArray<DeliveryLine>,
|
|||
|
|
dnLines: ReadonlyArray<DeliveryLine>,
|
|||
|
|
): boolean {
|
|||
|
|
if (orderLines.length === 0 || dnLines.length === 0) return false;
|
|||
|
|
if (orderLines.length !== dnLines.length) return false;
|
|||
|
|
|
|||
|
|
const key = (l: DeliveryLine) => `${normalizeLine(l.description)} |