245 lines
9.6 KiB
TypeScript
245 lines
9.6 KiB
TypeScript
/**
|
|
* 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;
|
|
/** 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,
|
|
}));
|
|
|
|
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}"`,
|
|
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 }>>();
|
|
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 }>({
|
|
filter: chunk.map((d) => `delivery_note_number = "${esc(d.delivery_note_number)}"`).join(" || "),
|
|
fields: "order,delivery_note_number,description,quantity",
|
|
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,
|
|
}));
|
|
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;
|
|
}
|