feat(orders): widen DN repeat-match to any status, add 60-day window

This commit is contained in:
Dave 2026-08-30 22:18:02 +02:00
parent 75b1e524ad
commit a6d88cef1c
2 changed files with 122 additions and 33 deletions

View file

@ -224,6 +224,89 @@ describe("suggestDeliveryNoteForOrder", () => {
expect(res.best?.deliveryNoteId).toBe("dn2");
});
it("matches a cancelled delivery note (status no longer filters candidates)", async () => {
// Per 2026-08-30 business decision: match against a DN of ANY
// status, including cancelled. The prior exclusion missed real
// repeat orders where the original delivery's DN was cancelled
// for reasons unrelated to whether goods went out.
const dnItem = { description: "M16x50 Bolt", quantity: 300, order: "other-order" };
const orderItem = { description: "M16x50 Bolt", quantity: 300 };
mockAdminPb(makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "co1" }),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getFullList: vi.fn()
.mockResolvedValueOnce([orderItem]) // for the order's own items
.mockResolvedValueOnce([dnItem]), // for the DN's items (matching)
},
[DELIVERY_NOTES]: {
getFullList: vi.fn().mockResolvedValue([
{
id: "dn-cancelled",
delivery_note_number: "DN-CANC-1",
company: "co1",
status: "cancelled",
created: new Date().toISOString(),
order: "other-order",
},
]),
},
}));
const result = await suggestDeliveryNoteForOrder("ord1");
expect(result.best?.deliveryNoteId).toBe("dn-cancelled");
});
it("does not match a delivery note older than 60 days", async () => {
const orderItem = { description: "M16x50 Bolt", quantity: 300 };
const dnItem = { description: "M16x50 Bolt", quantity: 300, order: "other-order" };
// Filter-aware mock: honours the `created >= "..."` clause the
// service now sends, so the test actually exercises the cutoff
// rather than passing vacuously because the mock returns all
// rows regardless of filter.
const cutoff = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)
.toISOString()
.replace("T", " ");
const dnRows = [
{
id: "dn-old",
delivery_note_number: "DN-OLD-1",
company: "co1",
status: "completed",
// > 60 days before "now" — must be filtered out by the cutoff
created: "2026-05-01 00:00:00.000Z",
order: "other-order",
},
];
const dnGetFullList = vi
.fn()
.mockImplementation((opts: { filter?: string } = {}) =>
dnRows.filter((dn) => dn.created >= cutoff),
);
mockAdminPb(makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ id: "ord1", company: "co1" }),
update: vi.fn().mockResolvedValue({}),
},
[ORDER_ITEMS]: {
getFullList: vi.fn()
.mockResolvedValueOnce([orderItem]) // for the order's own items
.mockResolvedValueOnce([dnItem]), // for the DN's items (would match)
},
[DELIVERY_NOTES]: { getFullList: dnGetFullList },
}));
const result = await suggestDeliveryNoteForOrder("ord1");
expect(result.best).toBeNull();
});
});
/* ============================================================================
@ -265,7 +348,12 @@ describe("suggestDeliveryNoteForOrder — scoping", () => {
expect(filter).not.toContain("customer");
});
it("restricts candidates to statuses whose goods actually went out", async () => {
it("does not filter candidates by status — any status qualifies (cancelled included)", async () => {
// Per 2026-08-30 business decision: match against a DN of ANY
// status, including `cancelled`. The 60-day window below bounds
// the search space instead. Asserting the absence of any `status =`
// clause pins the policy so a future revert (re-introducing a
// status allowlist) would fail here loudly.
const dnGetFullList = vi.fn().mockResolvedValue([]);
mockAdminPb(
makePb({
@ -281,13 +369,29 @@ describe("suggestDeliveryNoteForOrder — scoping", () => {
await suggestDeliveryNoteForOrder("ord1");
const filter = dnGetFullList.mock.calls[0][0].filter as string;
// draft + loaded are real deliveries here — confirmed with the
// business owner. Excluding them made the feature inert (3 of 116).
for (const s of ["draft", "loaded", "sent", "po_received", "invoiced", "completed"]) {
expect(filter).toContain(`status = "${s}"`);
}
// Cancelled is the one deliberate exclusion.
expect(filter).not.toContain('status = "cancelled"');
expect(filter).not.toMatch(/status\s*=/);
});
it("applies a 60-day candidate window on `delivery_notes.created`", async () => {
// The replacement for the prior status allowlist: bound the search
// space with a recency cutoff so the matcher can't surface a DN
// from years ago that happens to share a description.
const dnGetFullList = vi.fn().mockResolvedValue([]);
mockAdminPb(
makePb({
[ORDER_ITEMS]: {
getFullList: vi.fn().mockResolvedValue([
{ id: "oi1", description: "Widget", quantity: 1 },
]),
},
[DELIVERY_NOTES]: { getFullList: dnGetFullList },
}),
);
await suggestDeliveryNoteForOrder("ord1");
const filter = dnGetFullList.mock.calls[0][0].filter as string;
expect(filter).toMatch(/created\s*>=\s*"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
});
it("never flags an order as a repeat of its OWN delivery note", async () => {

View file

@ -34,30 +34,11 @@ const DELIVERY_NOTES_COLLECTION = "delivery_notes";
const QUOTES_COLLECTION = "quotes";
const QUOTE_ITEMS_COLLECTION = "quote_items";
/** DNs representing goods that actually went out to the customer.
*
* `draft` and `loaded` are INCLUDED, corrected from the original spec:
* - `draft` — confirmed with the business owner (2026-08-30) that
* drafts are real deliveries; the status simply never gets
* advanced in the system. Excluding them left a candidate pool of
* 3 DNs out of 116, making the whole feature inert.
* - `loaded` — reachable directly from `draft`
* (`deliveries-service.ts`), so a DN can go out on the truck
* without ever passing through `sent`. Goods physically left.
*
* `cancelled` remains excluded: nominally "abandoned, kept for audit".
* NOTE it is 84 of 116 live DNs, and the owner has said cancel was
* also used to clear out superseded delivery orders — so some of
* those may be real deliveries. Revisit once that is confirmed;
* including them is a one-line change here. */
const CANDIDATE_STATUSES = [
"draft",
"loaded",
"sent",
"po_received",
"invoiced",
"completed",
];
// Per 2026-08-30 business decision: match against a DN of ANY status,
// including `cancelled` — the prior exclusion missed real repeat orders
// where the original delivery's DN had been cancelled for reasons
// unrelated to whether goods went out. A 60-day candidate window
// (computed below) bounds the search space instead.
function esc(s: string): string {
return (s || "").replace(/"/g, '\\"');
@ -113,10 +94,14 @@ export async function suggestDeliveryNoteForOrder(
quantity: Number(r.quantity) || 0,
}));
const WINDOW_DAYS = 60;
const cutoff = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
const cutoffStr = cutoff.toISOString().replace("T", " ");
const candidates = await pb
.collection(DELIVERY_NOTES_COLLECTION)
.getFullList<DnCandidate>({
filter: `company = "${esc(order.company)}" && (${CANDIDATE_STATUSES.map((s) => `status = "${s}"`).join(" || ")})`,
filter: `company = "${esc(order.company)}" && created >= "${cutoffStr}"`,
fields: "id,delivery_note_number,company,status,created,order",
batch: 1000,
});