docs(orders): spec + plan for delivery-note repeat-order detection
This commit is contained in:
parent
21bc57797b
commit
0083199ed2
2 changed files with 1275 additions and 0 deletions
981
docs/superpowers/plans/2026-08-29-order-delivery-note-match.md
Normal file
981
docs/superpowers/plans/2026-08-29-order-delivery-note-match.md
Normal file
|
|
@ -0,0 +1,981 @@
|
|||
# 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)} | ||||