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:
parent
e09de3cc80
commit
3cf2837c3f
2 changed files with 411 additions and 0 deletions
227
src/lib/server/order-delivery-note-link-service.test.ts
Normal file
227
src/lib/server/order-delivery-note-link-service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
184
src/lib/server/order-delivery-note-link-service.ts
Normal file
184
src/lib/server/order-delivery-note-link-service.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue