feat(orders): flag exact order_number duplicates, any status, no time limit

This commit is contained in:
Dave 2026-08-30 22:04:57 +02:00
parent d59a2a1825
commit 9ec4cf2421
2 changed files with 250 additions and 0 deletions

View file

@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, 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;
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 } = {}) {
return where(name, opts.filter || "");
},
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 };
// Honor the `expand` option: attach each comma-separated
// relation as a nested `expand.<name>` on the returned
// record. Matches the real PocketBase SDK shape (consumer
// code reads `record.expand?.quote`), not a flat
// `expand_<name>` sentinel.
//
// The mock's collection map is keyed on the actual PB
// collection name (e.g. `quotes`, plural), while relation
// names in `expand` are singular (e.g. `quote`). Try the
// relation name as-is first, then the pluralised form, so
// tests can populate the related collection with the name
// they already use.
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("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service";
beforeEach(() => clearStore());
describe("suggestOrderNumberDuplicate", () => {
it("flags two orders that share the same order_number, regardless of status", async () => {
await pbMock.collection("orders").create({
id: "order-a",
order_number: "NO-REF",
status: "delivered",
});
const orderB = await pbMock.collection("orders").create({
id: "order-b",
order_number: "NO-REF",
status: "new",
});
const result = await suggestOrderNumberDuplicate(orderB.id);
expect(result.duplicateOrderId).toBe("order-a");
const updated = await pbMock.collection("orders").getOne("order-b");
expect(updated.order_number_duplicate).toBe("order-a");
expect(updated.order_number_duplicate_checked).toBe(true);
});
it("does not flag when order_number is unique", async () => {
const order = await pbMock.collection("orders").create({
id: "order-solo",
order_number: "UNIQUE-123",
});
const result = await suggestOrderNumberDuplicate(order.id);
expect(result.duplicateOrderId).toBeNull();
const updated = await pbMock.collection("orders").getOne("order-solo");
expect(updated.order_number_duplicate).toBeFalsy();
expect(updated.order_number_duplicate_checked).toBe(true);
});
it("prefers the oldest other order when more than two orders share a number", async () => {
await pbMock.collection("orders").create({
id: "order-oldest",
order_number: "SHARED",
created: "2026-01-01 00:00:00.000Z",
});
await pbMock.collection("orders").create({
id: "order-middle",
order_number: "SHARED",
created: "2026-02-01 00:00:00.000Z",
});
const orderNewest = await pbMock.collection("orders").create({
id: "order-newest",
order_number: "SHARED",
created: "2026-03-01 00:00:00.000Z",
});
const result = await suggestOrderNumberDuplicate(orderNewest.id);
expect(result.duplicateOrderId).toBe("order-oldest");
});
});

View file

@ -0,0 +1,68 @@
/**
* order-number-duplicate-link-service — detects when an order's
* `order_number` is reused by ANOTHER order (any status, no time
* limit). Catches two cases that have bitten the operator:
* - internal ORD-number collisions from a partial intake race
* - operator not realising a PO was already entered
*
* 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.
*
* NOTE: customer PO refs (e.g. "PO-12345") and placeholders like
* "NO-REF" are ALLOWED to repeat by design — one blanket customer PO
* is often split into several partial-delivery orders. This matcher
* flags any exact-string duplicate regardless of shape; the order
* review collision check (orders-service.ts) is the only place that
* gates on ORD-YYYY-MM-NNN shape.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
const ORDER_COLLECTION = "orders";
export interface OrderNumberDuplicateResult {
orderId: string;
duplicateOrderId: string | null;
}
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
}
export async function suggestOrderNumberDuplicate(
orderId: string,
): Promise<OrderNumberDuplicateResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const order = await pb
.collection(ORDER_COLLECTION)
.getOne<{ id: string; order_number?: string }>(orderId);
if (!order.order_number) {
return { orderId, duplicateOrderId: null };
}
const others = await pb
.collection(ORDER_COLLECTION)
.getFullList<{ id: string; created: string }>({
filter: `order_number = "${esc(order.order_number)}" && id != "${esc(orderId)}"`,
fields: "id,created",
sort: "created",
batch: 500,
});
const duplicateOrderId = others.length > 0 ? others[0].id : null;
await pb.collection(ORDER_COLLECTION).update(orderId, {
order_number_duplicate: duplicateOrderId,
order_number_duplicate_checked: true,
});
return { orderId, duplicateOrderId };
}