WFDASH-WEBAPP/src/lib/server/order-delivery-note-link-service.ts

248 lines
9.8 KiB
TypeScript
Raw Normal View History

/**
* order-delivery-note-link-service — detects when a new order is an
* exact repeat of a past delivery note's items, scoped to the same
* company (not customer — a different person at the same company can
* place the repeat order).
*
* Visibility only. Nothing downstream reads this to drive pricing,
* supplier assignment, or processing — a wrong flag costs nothing, the
* operator just ignores the pill. That's what justifies writing the
* match automatically with no human confirmation step, unlike the
* order↔quote matcher's deliberately conservative suggest-only policy
* (see order-quote-link-service.ts) — that policy exists because a
* wrong quote link puts wrong prices in front of a buyer.
*
* Called from inside createOrder's existing after()/E468 guard
* (orders-service.ts) — this function lets errors propagate to that
* caller rather than double-guarding.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match";
export type DeliveryNoteMatchMethod = "exact_items" | "no_match";
export interface SuggestDeliveryNoteResult {
orderId: string;
best: { deliveryNoteId: string; deliveryNoteNumber: string } | null;
method: DeliveryNoteMatchMethod;
}
const ORDER_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
const DELIVERY_NOTES_COLLECTION = "delivery_notes";
const QUOTES_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
/** Maximum age (in days) of a delivery-note candidate considered for
* repeat matching. Beyond this window, even an exact-line match is
* treated as a coincidence, not a repeat order. */
const WINDOW_DAYS = 60;
/** PB rejects filter expressions longer than ~8 KB (HTTP 400 "Something
* went wrong while processing your request."). Each candidate adds
* roughly 40 chars (`delivery_note_number = "<up-to-20-char-no>" || `),
* so 200 candidates keeps the filter at ~8 KB — at the limit. Using
* 200 still collapses ~190 individual fetches per chunk while keeping
* every individual filter comfortably parseable. */
const BULK_FILTER_CHUNK_SIZE = 100;
// Per 2026-08-30 business decision: match against a DN of ANY status,
// including `cancelled` (previously excluded along with a hardcoded
// allowlist of "active" statuses — see git history) — the prior
// exclusion missed real repeat orders where the original delivery's DN
// had been cancelled for reasons unrelated to whether goods went out.
// A 60-day candidate window (computed below) bounds the search space
// instead.
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
}
interface DnCandidate {
id: string;
delivery_note_number: string;
company?: string;
status: string;
created: string;
fix(orders): stop an order matching its own delivery note; include draft/loaded Two coupled corrections to delivery-note repeat detection. Self-match: delivery notes have no line-item collection -- items are order_items rows tagged with delivery_note_number. The Path A flow stamps that number onto rows that KEEP their `order` relation, so a processed order and its own DN are backed by the same database rows. isExactItemMatch compared them against themselves and returned true unconditionally. 149 live rows are in that state. Guarded both by DN id and, for combined DNs whose `order` is empty, by inspecting the DN rows' own order relation. Candidate statuses: `draft` and `loaded` are real deliveries here. The owner confirmed drafts simply never get advanced in the system, and `loaded` is reachable directly from draft, so goods can go out without ever passing through `sent`. Excluding both left a candidate pool of 3 DNs out of 116, making the feature inert. Ordering matters: the self-match guard had to land first, because the DN that exposed the bug is itself a draft. Cancelled stays excluded pending confirmation -- it is 84 of 116 and may have been used to clear superseded delivery orders rather than to mark abandonment. Tests: a prior review showed by mutation that the existing tests assert output shape but pin no scoping decision -- company-vs-customer, the status filter and the tie-break could all be inverted with the suite green. Added filter-inspecting and self-match tests, each verified by mutation to fail when its guard is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:09:23 +00:00
/** The order this DN's items originated from (Path A). Empty for
* ad-hoc counter DNs and for combined DNs spanning several orders.
* Read solely to skip an order's own delivery note — see the guard
* in the candidate loop. */
order?: string;
}
export async function suggestDeliveryNoteForOrder(
orderId: string,
): Promise<SuggestDeliveryNoteResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb
.collection(ORDER_COLLECTION)
.getOne<{ id: string; company?: string }>(orderId);
if (!order.company) {
// No signal to match on — deliberately does not write
// `delivery_note_matching_attempted`, unlike the quote matcher's
// "no_match" path. A never-attempted order (no company at all) is
// a different state from "we looked and found nothing," and a
// company gets added later, so leaving `attempted: false` lets a
// future sweep (out of scope for this iteration) reconsider it.
return { orderId, best: null, method: "no_match" };
}
const orderItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{
id: string;
description?: string;
quantity?: number;
selling_price?: number;
}>({
filter: `order = "${esc(orderId)}"`,
fields: "id,description,quantity,selling_price",
batch: 1000,
});
const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
const WINDOW_MS = WINDOW_DAYS * 24 * 60 * 60 * 1000;
const cutoff = new Date(Date.now() - WINDOW_MS);
const cutoffStr = cutoff.toISOString().replace("T", " ");
const candidates = await pb
.collection(DELIVERY_NOTES_COLLECTION)
.getFullList<DnCandidate>({
filter: `company = "${esc(order.company)}" && created >= "${cutoffStr}"`,
fix(orders): stop an order matching its own delivery note; include draft/loaded Two coupled corrections to delivery-note repeat detection. Self-match: delivery notes have no line-item collection -- items are order_items rows tagged with delivery_note_number. The Path A flow stamps that number onto rows that KEEP their `order` relation, so a processed order and its own DN are backed by the same database rows. isExactItemMatch compared them against themselves and returned true unconditionally. 149 live rows are in that state. Guarded both by DN id and, for combined DNs whose `order` is empty, by inspecting the DN rows' own order relation. Candidate statuses: `draft` and `loaded` are real deliveries here. The owner confirmed drafts simply never get advanced in the system, and `loaded` is reachable directly from draft, so goods can go out without ever passing through `sent`. Excluding both left a candidate pool of 3 DNs out of 116, making the feature inert. Ordering matters: the self-match guard had to land first, because the DN that exposed the bug is itself a draft. Cancelled stays excluded pending confirmation -- it is 84 of 116 and may have been used to clear superseded delivery orders rather than to mark abandonment. Tests: a prior review showed by mutation that the existing tests assert output shape but pin no scoping decision -- company-vs-customer, the status filter and the tie-break could all be inverted with the suite green. Added filter-inspecting and self-match tests, each verified by mutation to fail when its guard is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:09:23 +00:00
fields: "id,delivery_note_number,company,status,created,order",
batch: 1000,
});
// Self-skip DNs that belong to the order being matched — both the
// `dn.order === orderId` direct case and the combined-DN case where
// any of the DN's items point back to this order. Both checks need
// the DN's items, which we fetch ONCE below.
const candidatesToFetch = candidates.filter((dn) => !(dn.order && dn.order === orderId));
let matched: DnCandidate[] = [];
if (candidatesToFetch.length > 0) {
// Bulk-fetch ALL items for ALL candidate DNs, chunked to stay under
// PB's filter-expression length limit. The chunked query still
// collapses N per-candidate getFullList calls into ceil(N/200) —
// ~190x faster than the old N+1 loop, while never producing a single
// filter larger than ~8 KB. delivery_note_number is the join key —
// group rows by it.
const itemsByDn = new Map<string, Array<{ order?: string; description?: string; quantity?: number; selling_price?: number }>>();
for (let i = 0; i < candidatesToFetch.length; i += BULK_FILTER_CHUNK_SIZE) {
const chunk = candidatesToFetch.slice(i, i + BULK_FILTER_CHUNK_SIZE);
const chunkItems = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ order?: string; delivery_note_number?: string; description?: string; quantity?: number; selling_price?: number }>({
filter: chunk.map((d) => `delivery_note_number = "${esc(d.delivery_note_number)}"`).join(" || "),
fields: "order,delivery_note_number,description,quantity,selling_price",
batch: 5000,
});
for (const row of chunkItems) {
if (!row.delivery_note_number) continue;
const bucket = itemsByDn.get(row.delivery_note_number);
if (bucket) bucket.push(row);
else itemsByDn.set(row.delivery_note_number, [row]);
}
}
for (const dn of candidatesToFetch) {
const dnItemRows = itemsByDn.get(dn.delivery_note_number) ?? [];
// Second half of the self-skip: a combined DN (see
// `combineDeliveryNotes`) has `dn.order` empty because its items
// span several orders. The id check above can't catch those, so
// fall back to checking whether any of the DN's rows belong to the
// order we're matching.
if (dnItemRows.some((r) => r.order && r.order === orderId)) continue;
const dnLines: DeliveryLine[] = dnItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
price: Number(r.selling_price) || 0,
}));
if (isExactItemMatch(orderLines, dnLines)) matched.push(dn);
}
}
let best: DnCandidate | null = null;
if (matched.length === 1) {
best = matched[0];
} else if (matched.length > 1) {
best = (await breakTieByQuotePrice(pb, matched, orderItemRows)) ?? mostRecent(matched);
}
await pb.collection(ORDER_COLLECTION).update(orderId, {
potential_delivery_note: best ? best.id : null,
potential_delivery_note_number: best ? best.delivery_note_number : "",
delivery_note_match_confidence: best ? 1 : 0,
delivery_note_match_method: best ? "exact_items" : "no_match",
delivery_note_matching_attempted: true,
});
return {
orderId,
best: best
? { deliveryNoteId: best.id, deliveryNoteNumber: best.delivery_note_number }
: null,
method: best ? "exact_items" : "no_match",
};
}
function mostRecent(candidates: DnCandidate[]): DnCandidate {
return [...candidates].sort((a, b) =>
(b.created || "").localeCompare(a.created || ""),
)[0];
}
/** Among tied exact-item-match candidates, prefer the one whose priced
* quote (status="delivery_note") also matches the order's line
* prices. Returns null if no candidate's quote prices match — caller
* falls back to most-recent. */
async function breakTieByQuotePrice(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
candidates: DnCandidate[],
orderItemRows: Array<{ description?: string; selling_price?: number }>,
): Promise<DnCandidate | null> {
const priceByDesc = new Map<string, number>();
for (const r of orderItemRows) {
priceByDesc.set(
(r.description || "").trim().toLowerCase(),
Number(r.selling_price) || 0,
);
}
for (const dn of candidates) {
const quotes = await pb
.collection(QUOTES_COLLECTION)
.getFullList<{ id: string; delivery_note: string }>({
filter: `delivery_note = "${esc(dn.id)}" && status = "delivery_note"`,
fields: "id,delivery_note",
batch: 5,
});
if (quotes.length === 0) continue;
const quoteItems = await pb
.collection(QUOTE_ITEMS_COLLECTION)
.getFullList<{ description?: string; selling_price?: number }>({
filter: `quote = "${esc(quotes[0].id)}"`,
fields: "description,selling_price",
batch: 1000,
});
const allPricesMatch = quoteItems.every((qi) => {
const orderPrice = priceByDesc.get((qi.description || "").trim().toLowerCase());
return orderPrice !== undefined && orderPrice === (Number(qi.selling_price) || 0);
});
if (allPricesMatch && quoteItems.length > 0) return dn;
}
return null;
}