148 lines
5.8 KiB
TypeScript
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",
|
|
};
|
|
}
|