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).
284 lines
No EOL
9 KiB
TypeScript
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();
|
|
});
|
|
}); |