feat(orders): add delivery-note repeat-order detection service

Wraps the pure comparator with PB reads/writes. Same shape as
suggestQuoteForOrder:
- skip when order has no company (don't stamp attempted)
- write potential_delivery_note + number + confidence + method +
  attempted:true on completion
- empty write when no candidate matched

Tie-break order:
1. priced quote match (status=delivery_note, selling_price == order)
2. most recent (created desc)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:46:13 +02:00
parent e09de3cc80
commit 3cf2837c3f
2 changed files with 411 additions and 0 deletions

View file

@ -0,0 +1,227 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { suggestDeliveryNoteForOrder } from "@/lib/server/order-delivery-note-link-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
const ORDERS = "orders";
const ORDER_ITEMS = "order_items";
const DELIVERY_NOTES = "delivery_notes";
const QUOTES = "quotes";
const QUOTE_ITEMS = "quote_items";
interface CollectionMock {
getOne?: ReturnType<typeof vi.fn>;
getFullList?: ReturnType<typeof vi.fn>;
update?: ReturnType<typeof vi.fn>;
}
function makePb(overrides: Partial<Record<string, CollectionMock>> = {}) {
const defaults: Record<string, CollectionMock> = {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "co1" }),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
[DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
[QUOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
};
const merged: Record<string, CollectionMock> = { ...defaults, ...overrides };
return {
autoCancellation: vi.fn(),
collection: (name: string) => {
const c = merged[name];
if (!c) throw new Error(`unexpected collection: ${name}`);
return c;
},
};
}
function mockAdminPb(pb: ReturnType<typeof makePb>) {
vi.mocked(getAdminPocketBase).mockResolvedValue(
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
);
}
const ORDER = { id: "ord1", company: "co1" };
describe("suggestDeliveryNoteForOrder", () => {
beforeEach(() => vi.clearAllMocks());
it("skips entirely when the order has no company (does not stamp attempted)", async () => {
// When there's no company, we deliberately don't stamp
// `delivery_note_matching_attempted` — a future sweep (or the
// operator adding a company later) should still get a chance
// to match this order. Distinct from "we tried and found nothing".
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "" }),
update,
},
}),
);
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("no_match");
expect(res.best).toBeNull();
expect(update).not.toHaveBeenCalled();
});
it("flags a single exact item+quantity match", async () => {
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
// The service calls getFullList twice: once for the
// order's own items, then for each candidate DN's
// items. First mock value = order items, subsequent
// mocks = DN items as they're fetched.
getFullList: vi
.fn()
// 1st call: order's own items
.mockResolvedValueOnce([
{ id: "oi1", description: "Hex Bolt M20", quantity: 50 },
])
// 2nd call: DN candidate's items
.mockResolvedValueOnce([
{ id: "di1", description: "hex bolt m20", quantity: 50 },
]),
},
[DELIVERY_NOTES]: {
getFullList: vi.fn().mockResolvedValue([
{
id: "dn1",
delivery_note_number: "DN26071401",
company: "co1",
status: "invoiced",
created: "2026-07-14T00:00:00Z",
},
]),
},
}),
);
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("exact_items");
expect(res.best).toEqual({
deliveryNoteId: "dn1",
deliveryNoteNumber: "DN26071401",
});
expect(update).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({
potential_delivery_note: "dn1",
potential_delivery_note_number: "DN26071401",
delivery_note_match_confidence: 1,
delivery_note_match_method: "exact_items",
delivery_note_matching_attempted: true,
}),
);
});
it("stamps attempted:true and clears fields on no_match", async () => {
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
getFullList: vi
.fn()
.mockResolvedValueOnce([
{ id: "oi1", description: "Widget", quantity: 1 },
]),
},
[DELIVERY_NOTES]: { getFullList: vi.fn().mockResolvedValue([]) },
}),
);
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.method).toBe("no_match");
expect(res.best).toBeNull();
expect(update).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({
potential_delivery_note: null,
potential_delivery_note_number: "",
delivery_note_match_confidence: 0,
delivery_note_match_method: "no_match",
delivery_note_matching_attempted: true,
}),
);
});
it("breaks a tie between two exact-matching DNs using the priced quote", async () => {
// Two DN candidates match the order exactly on items. dn2 has a
// priced quote whose line prices match the order's; dn1 has no
// priced quote. The service should prefer dn2.
const orderItems = [
{ id: "oi1", description: "Hex Bolt M20", quantity: 50, selling_price: 12 },
];
const update = vi.fn().mockResolvedValue({});
mockAdminPb(
makePb({
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
[ORDER_ITEMS]: {
getFullList: vi
.fn()
.mockResolvedValueOnce(orderItems) // order's own items
.mockResolvedValueOnce([
{ id: "d1", description: "hex bolt m20", quantity: 50 },
]) // dn1 items
.mockResolvedValueOnce([
{ id: "d2", description: "hex bolt m20", quantity: 50 },
]), // dn2 items
},
[DELIVERY_NOTES]: {
getFullList: vi.fn().mockResolvedValue([
{
id: "dn1",
delivery_note_number: "DN1",
company: "co1",
status: "invoiced",
created: "2026-05-01T00:00:00Z",
},
{
id: "dn2",
delivery_note_number: "DN2",
company: "co1",
status: "invoiced",
created: "2026-07-01T00:00:00Z",
},
]),
},
[QUOTES]: {
getFullList: vi
.fn()
.mockResolvedValueOnce([]) // dn1: no priced quote
.mockResolvedValueOnce([{ id: "q2", delivery_note: "dn2" }]), // dn2: yes
},
[QUOTE_ITEMS]: {
getFullList: vi
.fn()
.mockResolvedValueOnce([
{
id: "qi1",
quote: "q2",
description: "hex bolt m20",
selling_price: 12,
},
]),
},
}),
);
const res = await suggestDeliveryNoteForOrder("ord1");
expect(res.best?.deliveryNoteId).toBe("dn2");
});
});

View file

@ -0,0 +1,184 @@
/**
* 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";
/** DNs the customer has actually seen. A draft was never sent, a
* cancelled one was abandoned — neither can be "repeated." */
const CANDIDATE_STATUSES = ["sent", "po_received", "invoiced", "completed"];
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
}
interface DnCandidate {
id: string;
delivery_note_number: string;
company?: string;
status: string;
created: 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 candidates = await pb
.collection(DELIVERY_NOTES_COLLECTION)
.getFullList<DnCandidate>({
filter: `company = "${esc(order.company)}" && (${CANDIDATE_STATUSES.map((s) => `status = "${s}"`).join(" || ")})`,
fields: "id,delivery_note_number,company,status,created",
batch: 1000,
});
const matched: DnCandidate[] = [];
for (const dn of candidates) {
const dnItemRows = await pb
.collection(ORDER_ITEMS_COLLECTION)
.getFullList<{ description?: string; quantity?: number }>({
filter: `delivery_note_number = "${esc(dn.delivery_note_number)}"`,
fields: "description,quantity",
batch: 1000,
});
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;
}