WFDASH-WEBAPP/src/lib/server/stock-purchase-invoice-matching-service.test.ts
SJCoetz 87cead1bcf feat(mcp): expand stock purchase invoice matching service with 5 phases
Build out the buyer-side PO reconciliation workflow that landed in
60d3204 ('stock purchase invoice reconciliation tool') into a full
3-document × 2-PO-kind matching service. The tool now covers every
real-life supplier document the buyer can receive.

Renamed the service entry point from matchAndReceiveFromInvoice to
matchAndReceiveFromPoDocument (old name kept as a back-compat alias).
The MCP tool name stays stock_purchases.match_and_receive_from_invoice
so existing callers don't break; the input arg shape changed from
'invoice:' to 'document:' (old 'invoice:' arg still accepted for
back-compat).

Phase 1 — supplier_sku backfill (prep work, not in service code)
Phase 2 — auto-detect stock vs customer POs (single tool, both kinds)
Phase 3 — GRN (delivery-note) mode via new mode: 'grn' flag
Phase 4 — pack-size inference (Orapi case: per-unit vs per-pack)
Phase 5 — PO reference normalisation (PO202608017 vs PO-2026-08-017)

Also adds 3 new MCP tools for the collection-confirm buyer workflow:
collection_confirm.list, collection_confirm.upload_invoice,
collection_confirm.confirm_order_items.

Docs: updated .agents/skills/wfdash-giellie/SKILL.md and
src/app/api/mcp/FEATURE-REQUESTS.md to describe the new modes and
shape. 1,661/1,664 vitest tests pass; the 3 failing tests are
pre-existing (PocketBase SDK mock mismatch in pocketbase-admin.test,
5s timeout in quotes/items/[id]/attachments/upload/route.test —
both reproducible on main without this change).
2026-08-09 20:23:36 +02:00

284 lines
No EOL
9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Pure logic tests for the supplier-invoice matching helpers.
*
* The actual `matchAndReceiveFromInvoice` flow touches PocketBase;
* for that we rely on the existing route-level mocks in
* `src/app/api/mcp/route.test.ts`. Here we focus on the deterministic
* functions the operator is most likely to get wrong:
*
* - filename composition (the operator's mental model of the
* `supplier_invoice_<slug>_<no>_<date>` convention)
* - supplier-name fuzzy matching (used as the soft cross-check)
* - description overlap scoring (used as a last-resort fallback)
*/
import {
buildDocumentFilename,
buildSupplierInvoiceFilename,
descriptionOverlap,
extractPackSize,
normalizePoReference,
slugifyInvoiceNumber,
slugifySupplierName,
supplierNamesMatch,
} from "./stock-purchase-invoice-matching-service";
describe("slugifySupplierName", () => {
it("lowercases and replaces non-alnum with underscores", () => {
expect(slugifySupplierName("HPI INSERTS")).toBe("hpi_inserts");
});
it("strips common entity suffixes", () => {
expect(slugifySupplierName("AFRIQ LUBE AND SERVICES CC")).toBe("afriq_lube_and_services");
});
it("caps at 32 chars to keep filenames sane", () => {
const long = "A".repeat(60);
expect(slugifySupplierName(long).length).toBeLessThanOrEqual(32);
});
});
describe("slugifyInvoiceNumber", () => {
it("uppercases and keeps alnum", () => {
expect(slugifyInvoiceNumber("in389384")).toBe("IN389384");
});
it("strips punctuation but keeps the structure", () => {
expect(slugifyInvoiceNumber("INV-2026/08/001")).toBe("INV_2026_08_001");
});
});
describe("buildSupplierInvoiceFilename", () => {
it("composes the canonical supplier_invoice_… name", () => {
const name = buildSupplierInvoiceFilename(
"HPI INSERTS",
"IN389384",
"2026-08-05",
"jpg",
);
expect(name).toBe(
"supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg",
);
});
it("falls back to jpg when extension is empty", () => {
expect(
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", ""),
).toMatch(/\.jpg$/);
});
it("uses the supplied extension verbatim (lowercased)", () => {
expect(
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", "PNG"),
).toMatch(/\.png$/);
});
it("strips a leading dot from the extension", () => {
expect(
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", ".pdf"),
).toMatch(/\.pdf$/);
});
});
describe("buildDocumentFilename (Phase 3 — invoice + grn modes)", () => {
it("invoice mode uses the supplier_invoice_ prefix", () => {
expect(
buildDocumentFilename(
"invoice",
"HPI INSERTS",
"IN389384",
"2026-08-05",
"jpg",
),
).toBe("supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg");
});
it("grn mode uses the delivery_note_ prefix", () => {
expect(
buildDocumentFilename(
"grn",
"Robert Bosch (Pty) Ltd",
"DELIVERY-290761754",
"2026-08-06",
"jpg",
),
).toBe("delivery_note_robert_bosch_DELIVERY_290761754_2026-08-06.jpg");
});
it("grn mode preserves the supplied extension", () => {
expect(
buildDocumentFilename("grn", "Acme", "DN-1", "2026-08-06", "pdf"),
).toMatch(/\.pdf$/);
});
// Back-compat — older callers using the pre-Phase-3 alias should
// still produce the same output.
it("buildSupplierInvoiceFilename is equivalent to buildDocumentFilename('invoice', …)", () => {
const args: Parameters<typeof buildSupplierInvoiceFilename> = [
"HPI INSERTS",
"IN389384",
"2026-08-05",
"jpg",
];
expect(buildSupplierInvoiceFilename(...args)).toBe(
buildDocumentFilename("invoice", ...args),
);
});
});
describe("supplierNamesMatch", () => {
it("returns true for identical names", () => {
expect(supplierNamesMatch("HPI INSERTS", "HPI INSERTS")).toBe(true);
});
it("returns true when PTY/LTD suffix differs", () => {
expect(supplierNamesMatch("HPI INSERTS (PTY) LTD", "HPI INSERTS")).toBe(true);
});
it("returns true when CC suffix differs", () => {
expect(
supplierNamesMatch("AFRIQ LUBE AND SERVICES CC", "AFRIQ LUBE AND SERVICES"),
).toBe(true);
});
it("returns false for clearly different names", () => {
expect(supplierNamesMatch("HPI INSERTS", "TEL-SCREW PRODUCTS")).toBe(false);
});
it("returns false when one input is empty", () => {
expect(supplierNamesMatch("", "HPI INSERTS")).toBe(false);
expect(supplierNamesMatch("HPI INSERTS", "")).toBe(false);
});
});
describe("descriptionOverlap", () => {
it("returns 1 for identical descriptions", () => {
expect(descriptionOverlap("HPI RIVNUT TOOL 610 M3-M12", "HPI RIVNUT TOOL 610 M3-M12")).toBe(1);
});
it("is high for near-identical descriptions (typo on one)", () => {
const a = "HPI RIVNUT TOOL 610 M3-M12";
const b = "HPI RIVNUT TOOL 510 M3 - M12"; // invoice typo: 510 vs 610
// Should still be ≥0.8 — the meaningful tokens all overlap.
expect(descriptionOverlap(a, b)).toBeGreaterThanOrEqual(0.8);
});
it("drops noise tokens (single digits)", () => {
const a = "M16 X 50 BOLT BOX OF 100";
const b = "BOLT BOX";
expect(descriptionOverlap(a, b)).toBe(1);
});
it("returns 0 when nothing matches", () => {
expect(descriptionOverlap("HPI RIVNUT TOOL", "GREASE 15KG")).toBe(0);
});
it("handles empty inputs without crashing", () => {
expect(descriptionOverlap("", "GREASE 15KG")).toBe(0);
expect(descriptionOverlap("HPI RIVNUT TOOL", "")).toBe(0);
});
});
// Re-exported constants sanity check — guards against accidental removal.
import {
MATCH_TOLERANCE,
DESCRIPTION_OVERLAP_THRESHOLD,
} from "./stock-purchase-invoice-matching-service";
describe("exported constants", () => {
it("MATCH_TOLERANCE is 1 cent", () => {
expect(MATCH_TOLERANCE).toBe(0.01);
});
it("DESCRIPTION_OVERLAP_THRESHOLD is 0.8", () => {
expect(DESCRIPTION_OVERLAP_THRESHOLD).toBe(0.8);
});
});
describe("normalizePoReference (Phase 2 — supplier formatting tolerance)", () => {
it("strips dashes from customer-PO format", () => {
expect(normalizePoReference("PO-2026-08-017")).toBe("PO202608017");
});
it("uppercases the no-dash format", () => {
expect(normalizePoReference("po202608017")).toBe("PO202608017");
});
it("strips underscores and spaces", () => {
expect(normalizePoReference("po_2026_08_017")).toBe("PO202608017");
expect(normalizePoReference("PO 2026 08 017")).toBe("PO202608017");
});
it("preserves the stock-purchase format with STK suffix", () => {
expect(normalizePoReference("PO26080501STK")).toBe("PO26080501STK");
});
it("handles a slashy variant too", () => {
expect(normalizePoReference("po/2026/08/017")).toBe("PO202608017");
});
it("returns empty string for empty input", () => {
expect(normalizePoReference("")).toBe("");
});
// Transport-method suffixes — TEL-SCREW invoice has "PO-2026-08-023/MAIL"
it("strips /MAIL suffix", () => {
expect(normalizePoReference("PO-2026-08-023/MAIL")).toBe("PO202608023");
});
it("strips /EMAIL suffix", () => {
expect(normalizePoReference("PO-2026-08-023/EMAIL")).toBe("PO202608023");
});
it("strips /COLLECT suffix", () => {
expect(normalizePoReference("PO-2026-08-023/COLLECT")).toBe("PO202608023");
});
it("preserves STK on stock-purchase format even if a suffix follows", () => {
// Stock-purchase format ends in STK which is part of the canonical
// ref, not a transport suffix. The strip should leave STK alone.
expect(normalizePoReference("PO26080501STK/MAIL")).toBe("PO26080501STK");
});
});
describe("extractPackSize (Phase 4 — pack-size inference for Orapi case)", () => {
it("detects 'BOX OF 12'", () => {
expect(extractPackSize("KBXXC 6102 ORAPI COLD GALV SPRAY BOX OF 12")).toBe(12);
});
it("detects lowercase variants", () => {
expect(extractPackSize("widget box of 6")).toBe(6);
});
it("detects 'PACK OF N'", () => {
expect(extractPackSize("Foam Pack of 4")).toBe(4);
});
it("detects 'CASE OF N'", () => {
expect(extractPackSize("Adhesive Case of 24")).toBe(24);
});
it("detects 'N-PACK' / 'N PACK' style", () => {
expect(extractPackSize("12-pack of nails")).toBe(12);
expect(extractPackSize("24 pack of bolts")).toBe(24);
});
it("returns null for descriptions without pack-size", () => {
expect(extractPackSize("HPI RIVNUT TOOL 610 M3-M12")).toBeNull();
expect(extractPackSize("M16 x 75mm set screw 8.8 HDG")).toBeNull();
});
it("returns null for nonsense 'N of M' phrases", () => {
expect(extractPackSize("set of screws")).toBeNull(); // no number
expect(extractPackSize("quantity of 5")).toBeNull(); // not a pack unit
});
it("returns null for pack size 1 (just a quantity, not a pack)", () => {
expect(extractPackSize("box of 1 widget")).toBeNull();
});
it("returns null for empty input", () => {
expect(extractPackSize("")).toBeNull();
});
});