WFDASH-WEBAPP/docs/superpowers/specs/2026-08-29-order-delivery-note-match-design.md

14 KiB

Order ↔ delivery-note repeat-order detection

Date: 2026-08-29 Status: Draft — written directly to spec form per the user's request to save review cycles; assumptions are called out explicitly below rather than resolved through interactive brainstorming. Confirm or correct before handing to an implementer.

Problem

A recurring flow: an order is picked up and processed, a delivery note (DN) is raised for it, a quote is generated from that DN and sent to the customer, and the customer later sends back a fresh order for the same goods — a repeat of that delivery. Today nothing connects the new order back to the DN it repeats. The operator has no visual signal that "this order is basically DN26071401 again," even though the evidence is already in the data.

What already exists (confirmed by reading the code, not assumed)

  • delivery_notes collection (src/lib/types.ts:1069): delivery_note_number (unique, format DN{YY}{MM}{DD}{NN}), customer, company, order (optional — the original order the items came from, Path A only), status (draft → sent → po_received → invoiced → completed, or cancelled), delivery_date, customer_po.
  • DNs have no separate line-item collection. Items are order_items rows tagged by string equality: order_items.delivery_note_number === delivery_notes.delivery_note_number (delivery-notes-service.ts:141, :197). Each such row carries description, quantity, selling_price.
  • createQuoteFromDeliveryNote (delivery-notes-service.ts, the sent transition) creates a quotes row with status: "delivery_note" and sets quotes.delivery_note (relation, types.ts:268) back to the DN. This is "the quote we sent with the DN" the request refers to.
  • The order↔quote auto-linking feature just built (docs/superpowers/plans/2026-08-29-order-quote-linking.md) gives a template to follow: a pure matcher module, a PB-backed service, a hook into createOrder's existing after() block, and a colored pill on /orders. This feature reuses that shape rather than inventing a new one.

Assumptions made to keep this a single, boundable spec

State these back if any is wrong — each would change the design materially.

  1. Match scope is company, per the request ("customer won't work, someone else at the company can place the order"). delivery_notes.company is used directly — it does not need to be derived by joining through the DN's original order.
  2. The primary signal is an exact match, not a fuzzy score. "Items and quantities match 100%" is read as a literal set-equality test: every order line pairs to exactly one DN line with the same (normalized) description and the same quantity, every DN line is paired, and no line is left over on either side. This is deliberately stricter than the quote matcher's Jaccard scoring — there is no "possible" tier here. Either it is a complete match or it isn't shown at all.
  3. The quote-price signal is corroborating, not required. Read from "the first one will be the items exactly matches... because the order will be an exact match": item+quantity equality alone is sufficient to flag. The DN's priced quote is used only to break ties when an order's items match more than one DN exactly (a customer who reorders the same thing repeatedly).
  4. This is visibility only. No change to order processing, pricing, supplier assignment, or any automation triggered by a match. A wrong flag costs nothing — the operator just ignores the pill. This justifies writing the match automatically (no human confirmation step), unlike the quote matcher's deliberately conservative "suggest, don't link" policy — that policy existed because a wrong quote link puts wrong prices in front of a buyer; nothing analogous is at stake here.
  5. Candidate DNs are scoped to status in sent | po_received | invoiced | completed. A draft DN was never sent to the customer, so an order can't be "repeating" it yet. cancelled is excluded as abandoned.
  6. No backlog sweep in this iteration. Only orders created from now on get checked, mirroring how the quote-matching sweep was scoped as a separate, later concern. Can be added the same way later if wanted.
  7. Detection piggybacks on the existing createOrder → after() hook rather than adding a second after() call, to reuse the already-reviewed E468 guard rather than duplicating it.

Correction to §3 while drafting this document — read before implementing: getOrdersDashboardData in orders-service.ts (the function backing the /api/v1/orders/list route the orders page actually calls — the page does not expand relations client-side) already made this exact tradeoff once for potential_quote and documented it. Section 2 below covers the fix (a denormalized number field); flagging it here too since it's the kind of thing an implementer skimming past the schema section could miss and then wire the pill to order.expand?.potential_delivery_note anyway, silently reintroducing the cost this spec exists partly to avoid.

Design

1. Pure comparison — src/lib/server/delivery-note-order-match.ts

No PocketBase, unit-testable in isolation, mirroring the separation already established in order-quote-match.ts.

export interface DeliveryLine {
  description: string;
  quantity: number;
}

/** Normalize for comparison: trim, lowercase, collapse internal whitespace.
 *  Not the quote matcher's tokenize/Jaccard — this is exact-string equality
 *  on a normalized form, not fuzzy overlap. */
export function normalizeLine(s: string): string;

/**
 * True iff every order line pairs to exactly one DN line with an equal
 * normalized description and equal quantity, every DN line is paired, and
 * neither side has anything left over. Order of lines does not matter
 * (multiset equality, not sequence equality) — duplicate lines are handled
 * by matching multiplicity, not just membership.
 */
export function isExactItemMatch(
  orderLines: ReadonlyArray<DeliveryLine>,
  dnLines: ReadonlyArray<DeliveryLine>,
): boolean;

2. Schema — new fields on orders, via migration script

Following the exact precedent of scripts/migrations/2026-06-27-add-order-quote-match-fields.ts (idempotent, pb.collections.update with a field-existence check, run as PB superuser). No PB admin UI step is needed — these are relation/text/bool/number fields, not select fields, so the AGENTS.md warning about manually adding enum values in the PB admin does not apply here.

  • potential_delivery_note — relation to delivery_notes, single, optional. Mirrors the shape of the existing orders.potential_quote. For programmatic use (joins, the on-demand re-check route, a future sweep) — not read by the list pill (see the note below on why).
  • potential_delivery_note_number — text, optional. Denormalized copy of delivery_notes.delivery_note_number, written at the same time as the relation. This is what the list pill actually renders. See rationale below — this field exists specifically to avoid a second expand cost on the orders list.
  • delivery_note_match_confidence — number, optional. Always 1 when set; kept for shape-consistency with the quote matcher and so a future fuzzy extension doesn't need a second migration.
  • delivery_note_match_method — text, optional. "exact_items" or "no_match". Text, not select, for the same forward-compatibility reason quote_match_method is text.
  • delivery_note_matching_attempted — bool, optional, default false.

Why a denormalized number field, corrected from an earlier draft of this spec: getOrdersDashboardData (orders-service.ts:253) deliberately drops potential_quote from its expand — the comment there states PB issues a separate IN query per expanded relation, so the existing "Suggested" quote pill shows only a static label, not the quote number, specifically to avoid that extra round-trip on every list load and every realtime refetch. Adding a second expanded relation (potential_delivery_note) would double that cost the codebase already went out of its way to avoid.

But the request specifically asks for the DN number to be visible on the pill — unlike the quote pill's generic "Suggested" label, this one is supposed to read DN26071401. Denormalizing the number as a plain text field sidesteps the tradeoff entirely: the list query needs zero additional expands, and delivery_note_number is immutable once assigned (server- generated, never edited per its type doc in types.ts:1071-1078), so there is no staleness risk from copying it. suggestDeliveryNoteForOrder writes both fields in the same update.

export interface SuggestDeliveryNoteResult {
  orderId: string;
  best: { deliveryNoteId: string; deliveryNoteNumber: string } | null;
  method: "exact_items" | "no_match";
}

export async function suggestDeliveryNoteForOrder(
  orderId: string,
): Promise<SuggestDeliveryNoteResult>;

Behaviour:

  1. Load the order and its order_items (description, quantity).
  2. Query candidate delivery_notes where company = order.company and status in the scoped set (§Assumption 5). Skip entirely if order.company is empty — no signal to match on.
  3. For each candidate, fetch its items (order_items where delivery_note_number = candidate.delivery_note_number) and run isExactItemMatch.
  4. If exactly one candidate matches, that's best.
  5. If more than one candidate matches (the tie case): for each tied candidate, find its priced quote (quotes where delivery_note = candidate.id and status = "delivery_note"), compare its quote_items selling prices against the order's line prices for the same paired descriptions. Prefer the candidate whose prices also match; if still tied, prefer the most recently created DN.
  6. Write potential_delivery_note, potential_delivery_note_number, delivery_note_match_confidence, delivery_note_match_method, delivery_note_matching_attempted — always stamping attempted: true even on no_match (with the other fields cleared to null/empty), mirroring the quote matcher's audit convention.
  7. Never throws out to the caller — this is called from inside the existing createOrder → after() guard, which already handles logging a swallowed failure. This function itself should let errors propagate to that caller rather than double-guarding.

4. Wiring into intake

In orders-service.ts, inside the existing runQuoteMatch closure built for Task 6 of the quote-linking plan (orders-service.ts:2226-2235), add a second call alongside suggestQuoteForOrder:

async function runQuoteMatch() {
  try {
    await suggestQuoteForOrder(order.id);
  } catch (err) {
    console.error(`[createOrder] quote auto-detection failed for order ${order.id}:`, err);
  }
  try {
    await suggestDeliveryNoteForOrder(order.id);
  } catch (err) {
    console.error(`[createOrder] delivery-note match failed for order ${order.id}:`, err);
  }
}

Two independent try/catch blocks, not one wrapping both — a delivery-note match failure must not prevent the quote suggestion from landing, and vice versa. Reuses the single after()/E468 guard already reviewed; does not add a second after() call.

POST /api/v1/orders/[id]/delivery-note-match — same shape as the quote matcher's single-order re-check endpoint, for the case where an order's items were edited after intake. Thin wrapper calling suggestDeliveryNoteForOrder(id).

6. UI — orders list pill

src/app/(app)/orders/page.tsx, in the same badge row as the existing quote pills (:701-735). New pill, additive — an order can show both a quote pill and a delivery-note pill at once, they are independent facts. Reads the denormalized potential_delivery_note_number text field from §2 — no expand needed, no extra query cost:

{order.potential_delivery_note_number && (
  <span
    title={`Matches delivery note ${order.potential_delivery_note_number} — likely a repeat order`}
    className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
    style={{
      background: "rgba(59, 130, 246, 0.10)",
      color: "#3b82f6",
      boxShadow: "inset 0 0 0 1px rgba(59, 130, 246, 0.30)",
    }}
    aria-label={`Matches delivery note ${order.potential_delivery_note_number}`}
  >
    <Truck className="h-3 w-3" />
    {order.potential_delivery_note_number}
  </span>
)}

Blue, distinct from the quote pills' teal (linked) and amber (suggested) — this is a different kind of fact (a repeat-order observation, not a provenance link) and should not read as a variant of the quote pill. potential_delivery_note_number needs adding to OrderWithItems / Order in src/lib/types.ts, and to whatever field allowlist (if any) getOrdersDashboardData uses for the orders it returns — verify at implementation time whether that function selects specific fields or returns full records; §3's note about PB 0.26 dropping expand when fields is set suggests it currently returns full records, in which case no allowlist change is needed, but confirm rather than assume.

Out of scope

  • Any change to order processing, pricing, or supplier auto-fill based on the match.
  • A backlog sweep over existing orders (§Assumption 6).
  • Surfacing the match on the order detail or process pages — list-only for this iteration, since that's what was asked for ("visible... a pill").
  • Human confirmation / promotion flow — there is no soft/hard distinction here, unlike the quote matcher (§Assumption 4).

Testing

  • Pure comparison: unit tests for exact match, count mismatch, quantity mismatch, description-only-differs-by-case-or-whitespace (should still match), and duplicate-line multiplicity (two lines of the same item must require two DN lines, not one).
  • Service: mocked-PB tests mirroring order-quote-link-service.test.ts's harness style — single match, tie broken by price, tie broken by recency, no match, empty company skips entirely.
  • Intake wiring: a test proving a delivery-note match failure doesn't prevent the quote suggestion (and vice versa) — the two-try/catch requirement in §4 is the point of this test.
  • UI: manual verification in the browser against a real order+DN pair.