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

148 lines
5.8 KiB
TypeScript

/**
* order-order-duplicate-link-service — detects when a new order's
* line items (description + quantity, exact multiset match) exactly
* repeat another order for the SAME company, created within the last
* 60 days, REGARDLESS of either order's status.
*
* Visibility only — same reasoning as order-delivery-note-link-service
* and order-number-duplicate-link-service. This is deliberately a
* separate flag/pill from the delivery-note repeat check: this one
* catches "we already have an ORDER for this," the DN check catches
* "we already DELIVERED this" — different facts, different pills.
*
* PREREQUISITE: Before this code can run against a real PocketBase
* instance, the operator must add five fields to the `orders`
* collection in the PB admin UI (per the AGENTS.md convention — PB
* schema is not self-service from code):
* - `potential_duplicate_order` (Relation → orders, single)
* - `potential_duplicate_order_number` (Text)
* - `duplicate_order_match_confidence` (Number)
* - `duplicate_order_match_method` (Text)
* - `duplicate_order_matching_attempted` (Bool)
* Without these fields, the `.update()` below will 500 in production.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { isExactItemMatch, type DeliveryLine } from "@/lib/server/delivery-note-order-match";
const ORDER_COLLECTION = "orders";
const ORDER_ITEMS_COLLECTION = "order_items";
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 30 chars (`order = "<15-char-id>" || `), so 200 candidates
* keeps the filter at ~6 KB — comfortably under the limit while still
* collapsing ~190 individual fetches per chunk. */
const BULK_FILTER_CHUNK_SIZE = 100;
export type OrderDuplicateMatchMethod = "exact_items_same_company" | "no_match";
export interface SuggestOrderDuplicateResult {
orderId: string;
duplicateOrderId: string | null;
method: OrderDuplicateMatchMethod;
}
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
}
function pbDate(d: Date): string {
return d.toISOString().replace("T", " ");
}
export async function suggestOrderDuplicate(
orderId: string,
): Promise<SuggestOrderDuplicateResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb
.collection(ORDER_COLLECTION)
.getOne<{ id: string; company?: string; created: string }>(orderId);
if (!order.company) {
// No signal to match on — same "leave attempted unset" reasoning
// as the delivery-note matcher: a company gets added later and a
// future sweep should reconsider it.
return { orderId, duplicateOrderId: null, method: "no_match" };
}
const orderItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ description?: string; quantity?: number }>({
filter: `order = "${esc(orderId)}"`,
fields: "description,quantity",
batch: 1000,
});
const orderLines: DeliveryLine[] = orderItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
}));
const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
const candidates = await pb
.collection(ORDER_COLLECTION)
.getFullList<{ id: string; order_number: string; created: string }>({
filter: `company = "${esc(order.company)}" && id != "${esc(orderId)}" && created >= "${pbDate(cutoff)}"`,
fields: "id,order_number,created",
sort: "created",
batch: 500,
});
let match: { id: string; order_number: string } | null = null;
// Bulk-fetch ALL items for ALL candidates, 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 ~6 KB. Group items by candidate order id so we can compare each
// candidate's lines to the source order's lines in memory.
if (candidates.length > 0) {
const itemsByOrder = new Map<string, Array<{ description?: string; quantity?: number }>>();
for (let i = 0; i < candidates.length; i += BULK_FILTER_CHUNK_SIZE) {
const chunk = candidates.slice(i, i + BULK_FILTER_CHUNK_SIZE);
const chunkItems = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ order?: string; description?: string; quantity?: number }>({
filter: chunk.map((c) => `order = "${esc(c.id)}"`).join(" || "),
fields: "order,description,quantity",
batch: 5000,
});
for (const row of chunkItems) {
if (!row.order) continue;
const bucket = itemsByOrder.get(row.order);
if (bucket) bucket.push(row);
else itemsByOrder.set(row.order, [row]);
}
}
for (const candidate of candidates) {
const candidateItemRows = itemsByOrder.get(candidate.id) ?? [];
const candidateLines: DeliveryLine[] = candidateItemRows.map((r) => ({
description: r.description || "",
quantity: Number(r.quantity) || 0,
}));
if (isExactItemMatch(orderLines, candidateLines)) {
match = candidate;
break; // candidates sorted oldest-first — first hit is the original
}
}
}
await pb.collection(ORDER_COLLECTION).update(orderId, {
potential_duplicate_order: match ? match.id : null,
potential_duplicate_order_number: match ? match.order_number : "",
duplicate_order_match_confidence: match ? 1 : 0,
duplicate_order_match_method: match ? "exact_items_same_company" : "no_match",
duplicate_order_matching_attempted: true,
});
return {
orderId,
duplicateOrderId: match ? match.id : null,
method: match ? "exact_items_same_company" : "no_match",
};
}