feat(orders): flag exact item-match duplicates, same company, last 60 days
This commit is contained in:
parent
1a824d3477
commit
dae184ef57
2 changed files with 374 additions and 0 deletions
254
src/lib/server/order-order-duplicate-link-service.test.ts
Normal file
254
src/lib/server/order-order-duplicate-link-service.test.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const { store, pbMock, clearStore } = vi.hoisted(() => {
|
||||
type StoreMap = Map<string, Record<string, unknown>>;
|
||||
const store: Record<string, StoreMap> = {};
|
||||
let seq = 0;
|
||||
const nextId = () => `mock-${++seq}`;
|
||||
|
||||
const getOrCreateMap = (name: string): StoreMap => {
|
||||
if (!store[name]) store[name] = new Map();
|
||||
return store[name];
|
||||
};
|
||||
|
||||
const where = (name: string, filter: string): Record<string, unknown>[] => {
|
||||
const items = Array.from(getOrCreateMap(name).values());
|
||||
if (!filter || !filter.trim()) return items;
|
||||
const clauses = filter.split("&&").map((c) => c.trim());
|
||||
return items.filter((item) =>
|
||||
clauses.every((clause) => {
|
||||
const m = clause.match(/^(\w+)\s*([!=<>]+)\s*"?([^"]+)"?$/);
|
||||
if (!m) return true;
|
||||
const [, field, op, raw] = m;
|
||||
const value = raw.replace(/^"|"$/g, "");
|
||||
const actual = String(item[field] ?? "");
|
||||
if (op === "=") return actual === value;
|
||||
if (op === "!=") return actual !== value;
|
||||
// `created >= "2026-..."` clause — compare lexicographically.
|
||||
// Safe for the YYYY-MM-DD HH:MM:SS.sssZ format PB uses, which
|
||||
// sorts correctly as a string. Without this, every candidate
|
||||
// would pass and the 60-day window would be inert.
|
||||
if (op === ">=") return actual >= value;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const pbMock = {
|
||||
autoCancellation: vi.fn(),
|
||||
collection: (name: string) => ({
|
||||
autoCancellation: vi.fn(),
|
||||
async getList(
|
||||
_page = 1,
|
||||
_perPage = 25,
|
||||
opts: { filter?: string } = {},
|
||||
) {
|
||||
const items = where(name, opts.filter || "");
|
||||
return { items, totalItems: items.length, page: 1, perPage: _perPage };
|
||||
},
|
||||
async getFullList(opts: { filter?: string; sort?: string } = {}) {
|
||||
const items = where(name, opts.filter || "");
|
||||
// Honor `sort` so tests can pin behaviour that depends on row
|
||||
// order. The real PB SDK sorts by the requested field
|
||||
// ascending; descending requires a `-` prefix which we do not
|
||||
// currently model. Sufficient for the duplicate-matcher tests
|
||||
// that request `sort: "created"`.
|
||||
if (opts.sort) {
|
||||
const dir = opts.sort.startsWith("-") ? -1 : 1;
|
||||
const field = opts.sort.replace(/^-/, "");
|
||||
items.sort((a, b) => {
|
||||
const av = String(a[field] ?? "");
|
||||
const bv = String(b[field] ?? "");
|
||||
return dir * (av < bv ? -1 : av > bv ? 1 : 0);
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
async getOne(
|
||||
id: string,
|
||||
opts: { expand?: string } = {},
|
||||
): Promise<Record<string, unknown> & { expand?: Record<string, unknown> }> {
|
||||
const item = getOrCreateMap(name).get(id);
|
||||
if (!item) {
|
||||
const err = new Error("not found") as Error & { status: number };
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
if (!opts.expand) return { ...item };
|
||||
const expandObj: Record<string, unknown> = {};
|
||||
for (const rel of opts.expand.split(",").map((s) => s.trim()).filter(Boolean)) {
|
||||
const relatedId = (item as Record<string, unknown>)[rel];
|
||||
if (typeof relatedId !== "string" || !relatedId) continue;
|
||||
const related =
|
||||
getOrCreateMap(rel).get(relatedId) ??
|
||||
getOrCreateMap(`${rel}s`).get(relatedId) ??
|
||||
getOrCreateMap(rel.replace(/s$/, "")).get(relatedId);
|
||||
if (related) expandObj[rel] = { ...related };
|
||||
}
|
||||
return { ...item, expand: expandObj };
|
||||
},
|
||||
async create(data: Record<string, unknown>) {
|
||||
const item = {
|
||||
id: nextId(),
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
getOrCreateMap(name).set(item.id, item);
|
||||
return item;
|
||||
},
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const map = getOrCreateMap(name);
|
||||
const existing = map.get(id);
|
||||
if (!existing) {
|
||||
const err = new Error("not found") as Error & { status: number };
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
existing[key] = value;
|
||||
}
|
||||
existing.updated = new Date().toISOString();
|
||||
return { ...existing };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const clearStore = () => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
seq = 0;
|
||||
};
|
||||
|
||||
return { store, pbMock, clearStore };
|
||||
});
|
||||
|
||||
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
||||
}));
|
||||
|
||||
import { suggestOrderDuplicate } from "@/lib/server/order-order-duplicate-link-service";
|
||||
|
||||
async function seedOrderWithItems(
|
||||
orderId: string,
|
||||
fields: Record<string, unknown>,
|
||||
items: Array<{ description: string; quantity: number }>,
|
||||
) {
|
||||
await pbMock.collection("orders").create({ id: orderId, ...fields });
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await pbMock.collection("order_items").create({
|
||||
id: `${orderId}-item-${i}`,
|
||||
order: orderId,
|
||||
description: items[i].description,
|
||||
quantity: items[i].quantity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearStore();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-30T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("suggestOrderDuplicate", () => {
|
||||
it("flags an order whose items exactly match another order's, same company, within 60 days, regardless of status", async () => {
|
||||
await seedOrderWithItems(
|
||||
"order-original",
|
||||
{
|
||||
order_number: "ORD-2026-08-001",
|
||||
company: "comp-1",
|
||||
created: "2026-07-15 00:00:00.000Z", // 46 days before "now"
|
||||
status: "delivered", // status must not matter
|
||||
},
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
await seedOrderWithItems(
|
||||
"order-repeat",
|
||||
{
|
||||
order_number: "ORD-2026-08-090",
|
||||
company: "comp-1",
|
||||
created: "2026-08-30 00:00:00.000Z",
|
||||
status: "new",
|
||||
},
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
|
||||
const result = await suggestOrderDuplicate("order-repeat");
|
||||
|
||||
expect(result.duplicateOrderId).toBe("order-original");
|
||||
const updated = await pbMock.collection("orders").getOne("order-repeat");
|
||||
expect(updated.potential_duplicate_order).toBe("order-original");
|
||||
expect(updated.potential_duplicate_order_number).toBe("ORD-2026-08-001");
|
||||
expect(updated.duplicate_order_match_confidence).toBe(1);
|
||||
expect(updated.duplicate_order_match_method).toBe("exact_items_same_company");
|
||||
expect(updated.duplicate_order_matching_attempted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag when the matching order is for a different company", async () => {
|
||||
await seedOrderWithItems(
|
||||
"order-other-company",
|
||||
{ order_number: "ORD-A", company: "comp-2", created: "2026-08-01 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
await seedOrderWithItems(
|
||||
"order-mine",
|
||||
{ order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
|
||||
const result = await suggestOrderDuplicate("order-mine");
|
||||
|
||||
expect(result.duplicateOrderId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flag when the matching order is older than 60 days", async () => {
|
||||
await seedOrderWithItems(
|
||||
"order-too-old",
|
||||
{ order_number: "ORD-A", company: "comp-1", created: "2026-06-01 00:00:00.000Z" }, // 90 days before "now"
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
await seedOrderWithItems(
|
||||
"order-mine",
|
||||
{ order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
|
||||
const result = await suggestOrderDuplicate("order-mine");
|
||||
|
||||
expect(result.duplicateOrderId).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flag when item quantities differ", async () => {
|
||||
await seedOrderWithItems(
|
||||
"order-a",
|
||||
{ order_number: "ORD-A", company: "comp-1", created: "2026-08-01 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
await seedOrderWithItems(
|
||||
"order-b",
|
||||
{ order_number: "ORD-B", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 250 }], // different qty
|
||||
);
|
||||
|
||||
const result = await suggestOrderDuplicate("order-b");
|
||||
|
||||
expect(result.duplicateOrderId).toBeNull();
|
||||
});
|
||||
|
||||
it("never matches an order against itself", async () => {
|
||||
await seedOrderWithItems(
|
||||
"order-solo",
|
||||
{ order_number: "ORD-A", company: "comp-1", created: "2026-08-30 00:00:00.000Z" },
|
||||
[{ description: "M16x50 Bolt", quantity: 300 }],
|
||||
);
|
||||
|
||||
const result = await suggestOrderDuplicate("order-solo");
|
||||
|
||||
expect(result.duplicateOrderId).toBeNull();
|
||||
});
|
||||
});
|
||||
120
src/lib/server/order-order-duplicate-link-service.ts
Normal file
120
src/lib/server/order-order-duplicate-link-service.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* 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;
|
||||
|
||||
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", " ").replace("Z", "Z");
|
||||
}
|
||||
|
||||
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;
|
||||
for (const candidate of candidates) {
|
||||
const candidateItemRows = await pb
|
||||
.collection(ORDER_ITEMS_COLLECTION)
|
||||
.getFullList<{ description?: string; quantity?: number }>({
|
||||
filter: `order = "${esc(candidate.id)}"`,
|
||||
fields: "description,quantity",
|
||||
batch: 1000,
|
||||
});
|
||||
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",
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue