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_notescollection (src/lib/types.ts:1069):delivery_note_number(unique, formatDN{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, orcancelled),delivery_date,customer_po.- DNs have no separate line-item collection. Items are
order_itemsrows tagged by string equality:order_items.delivery_note_number === delivery_notes.delivery_note_number(delivery-notes-service.ts:141,:197). Each such row carriesdescription,quantity,selling_price. createQuoteFromDeliveryNote(delivery-notes-service.ts, thesenttransition) creates aquotesrow withstatus: "delivery_note"and setsquotes.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 intocreateOrder's existingafter()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.
- Match scope is
company, per the request ("customer won't work, someone else at the company can place the order").delivery_notes.companyis used directly — it does not need to be derived by joining through the DN's original order. - 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.
- 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).
- 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.
- Candidate DNs are scoped to
statusinsent | po_received | invoiced | completed. AdraftDN was never sent to the customer, so an order can't be "repeating" it yet.cancelledis excluded as abandoned. - 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.
- Detection piggybacks on the existing
createOrder→after()hook rather than adding a secondafter()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 todelivery_notes, single, optional. Mirrors the shape of the existingorders.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 ofdelivery_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 secondexpandcost on the orders list.delivery_note_match_confidence— number, optional. Always1when 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 reasonquote_match_methodis text.delivery_note_matching_attempted— bool, optional, defaultfalse.
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.
3. Service — src/lib/server/order-delivery-note-link-service.ts
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:
- Load the order and its
order_items(description, quantity). - Query candidate
delivery_noteswherecompany = order.companyandstatusin the scoped set (§Assumption 5). Skip entirely iforder.companyis empty — no signal to match on. - For each candidate, fetch its items (
order_itemswheredelivery_note_number = candidate.delivery_note_number) and runisExactItemMatch. - If exactly one candidate matches, that's
best. - If more than one candidate matches (the tie case): for each tied
candidate, find its priced quote (
quoteswheredelivery_note = candidate.idandstatus = "delivery_note"), compare itsquote_itemsselling 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. - Write
potential_delivery_note,potential_delivery_note_number,delivery_note_match_confidence,delivery_note_match_method,delivery_note_matching_attempted— always stampingattempted: trueeven onno_match(with the other fields cleared to null/empty), mirroring the quote matcher's audit convention. - 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.
5. On-demand re-check (optional but recommended)
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.