feat(mcp): add stock purchase invoice reconciliation tool
Add the full buyer-side supplier invoice reconciliation workflow: - Include the new `stock_purchases.match_and_receive_from_invoice` MCP tool for deterministic PO-invoice matching - Create core matching service with strict, rule-based line matching (sku → supplier_sku → description overlap) - Support dry-run default mode to validate matches before committing - Add automatic attachment handling with standard `supplier_invoice_*` naming convention - Add full test suite for the matching logic - Update documentation and agent skill profile to document the new tool and workflow Pre-existing test failures (3 total) are unrelated to this change.
This commit is contained in:
parent
63048e51c9
commit
60d320411d
9 changed files with 1415 additions and 12 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: wfdash-giellie
|
name: wfdash-giellie
|
||||||
description: Giellie — WFDASH business-CRM agent. Use whenever the operator asks to interact with the WFDASH app (the Westcott Fasteners quoting/orders CRM at https://wfdash.apphub.co.za) via API or MCP — e.g. ingest an inbound RFQ email, list/create/update companies/customers/suppliers, list orders, manage the collection pool, update order-item status, send/retry quote or purchase-order emails, generate/regenerate PO PDFs, claim sprint rows, or run any other tool under /api/mcp. Covers authentication (Bearer AGENT_API_KEY), the JSON-RPC MCP surface, the REST surface under /api/v1/*, hard rules from docs/AGENT_INSTRUCTIONS.md, the 73 tools currently exposed under tools/list, and a running list of known bugs and architectural gaps surfaced during smoke-testing.
|
description: Giellie — WFDASH business-CRM agent. Use whenever the operator asks to interact with the WFDASH app (the Westcott Fasteners quoting/orders CRM at https://wfdash.apphub.co.za) via API or MCP — e.g. ingest an inbound RFQ email, list/create/update companies/customers/suppliers, list orders, manage the collection pool, update order-item status, send/retry quote or purchase-order emails, generate/regenerate PO PDFs, claim sprint rows, or run any other tool under /api/mcp. Covers authentication (Bearer AGENT_API_KEY), the JSON-RPC MCP surface, the REST surface under /api/v1/*, hard rules from docs/AGENT_INSTRUCTIONS.md, the 86 tools currently exposed under tools/list, and a running list of known bugs and architectural gaps surfaced during smoke-testing.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Giellie — WFDASH Agent
|
# Giellie — WFDASH Agent
|
||||||
|
|
@ -197,7 +197,7 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
|
||||||
|
|
||||||
## Tool families (MCP)
|
## Tool families (MCP)
|
||||||
|
|
||||||
73 tools as of last sync. Grouped by job-to-be-done — pick the **most specific** tool that fits.
|
86 tools as of last sync. Grouped by job-to-be-done — pick the **most specific** tool that fits.
|
||||||
|
|
||||||
### 1. Quote email pipeline (read + write)
|
### 1. Quote email pipeline (read + write)
|
||||||
|
|
||||||
|
|
@ -259,6 +259,8 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote
|
||||||
| `stock_purchases.list`, `stock_purchases.get`, `stock_purchases.create`, `stock_purchases.next_number` | Stock-PO CRUD. |
|
| `stock_purchases.list`, `stock_purchases.get`, `stock_purchases.create`, `stock_purchases.next_number` | Stock-PO CRUD. |
|
||||||
| `stock_purchases.send`, `stock_purchases.receive` | Send to supplier / receive goods. |
|
| `stock_purchases.send`, `stock_purchases.receive` | Send to supplier / receive goods. |
|
||||||
| `stock_purchases.send_email`, `stock_purchases.retry_email` | Outbound email for stock POs. |
|
| `stock_purchases.send_email`, `stock_purchases.retry_email` | Outbound email for stock POs. |
|
||||||
|
| `stock_purchases.add_attachment` | Upload supplier invoice / packing slip / photo. PB appends a random token to the filename. Use the `supplier_invoice_<slug>_<no>_<YYYY-MM-DD>.<ext>` naming convention so invoices are filterable separately from generic attachments — see the example on PO26073109STK (`supplier_invoice_afriq_ina64721_2026_08_06_taod7gizh2.png`). |
|
||||||
|
| **`stock_purchases.match_and_receive_from_invoice`** | **Buyer-side invoice reconciliation (added 2026-08-09).** Parses a scanned supplier invoice, matches it to the PO by `purchase_number` (the "Your Ref" field), validates each line (sku / supplier_sku / description-overlap match + qty + cost), and either dry-runs or commits the receive. **Dry-run by default**; pass `confirm: true` to commit. Verdict drives the HTTP status — see "Supplier invoice matching" below. |
|
||||||
| `stock.fulfill`, `stock.surplus_book` | Fulfil from PO group / book surplus stock. |
|
| `stock.fulfill`, `stock.surplus_book` | Fulfil from PO group / book surplus stock. |
|
||||||
|
|
||||||
### 5. Sprint / buyer tasks
|
### 5. Sprint / buyer tasks
|
||||||
|
|
@ -324,6 +326,112 @@ If you need the input schema for any of these, call `tools/list` — do not hard
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Supplier invoice matching (buyer-side workflow)
|
||||||
|
|
||||||
|
The full happy path for "I just got a supplier invoice via WhatsApp":
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 1. The operator (or upstream channel) hands you the invoice bytes
|
||||||
|
// plus the buyer's instruction to book it.
|
||||||
|
// 2. You parse it with your native vision capability:
|
||||||
|
// document_no, date, supplier_name, lines: [{code, description,
|
||||||
|
// quantity, unit_price}]
|
||||||
|
// 3. Dry-run to surface mismatches BEFORE committing.
|
||||||
|
{
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
"arguments": {
|
||||||
|
"po_reference": "PO26080501STK", // "Your Ref" on the invoice
|
||||||
|
"invoice": {
|
||||||
|
"document_no": "IN389384",
|
||||||
|
"date": "2026-08-05",
|
||||||
|
"supplier_name": "H P I INSERTS (PTY) LTD",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"code": "56020510",
|
||||||
|
"description": "HPI RIVNUT TOOL 510 M3 - M12",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 1150
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"attachment": {
|
||||||
|
"filename": "IN389384.jpg",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"base64": "<bytes>"
|
||||||
|
}
|
||||||
|
// confirm: false (default) — dry-run only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response on a clean match:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"mode": "dry_run",
|
||||||
|
"would_receive": true,
|
||||||
|
"attachment_filename": "supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg",
|
||||||
|
"report": {
|
||||||
|
"verdict": "ready_to_receive",
|
||||||
|
"po_id": "x9fr3a67ho1wrzs",
|
||||||
|
"po_number": "PO26080501STK",
|
||||||
|
"po_status": "sent",
|
||||||
|
"po_supplier_name": "HPI INSERTS",
|
||||||
|
"invoice_supplier_name": "H P I INSERTS (PTY) LTD",
|
||||||
|
"ordered_value": 1150,
|
||||||
|
"received_value": 0,
|
||||||
|
"line_comparisons": [
|
||||||
|
{
|
||||||
|
"invoice_line_no": 1,
|
||||||
|
"po_line_id": "4rl2ql45wcllrqk",
|
||||||
|
"stock_item_id": "3w0evewxm3bocme",
|
||||||
|
"match_method": "sku_exact", // or supplier_sku_exact / description_overlap
|
||||||
|
"qty_delta": 0,
|
||||||
|
"cost_delta": 0,
|
||||||
|
"notes": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `would_receive: true` and the operator (or upstream channel) confirms, call again with `confirm: true` — on success this attaches the invoice and calls `stock_purchases.receive` in the same round-trip.
|
||||||
|
|
||||||
|
### Verdict → action map
|
||||||
|
|
||||||
|
| Verdict | Dry-run HTTP | Commit HTTP | Meaning | What to do |
|
||||||
|
|---------|--------------|-------------|---------|-----------|
|
||||||
|
| `ready_to_receive` | 200 | 200 | All lines matched; qty + cost agree | Commit with `confirm: true`, or report success to the buyer |
|
||||||
|
| `po_not_found` | 200 | 404 | `po_reference` didn't match any `stock_purchases.purchase_number` | Surface the error; ask the buyer to confirm the PO number on the invoice |
|
||||||
|
| `wrong_status` | 200 | 409 | PO isn't in `sent` (already received, cancelled, draft, …) | Report and stop — the receive path refuses non-`sent` POs |
|
||||||
|
| `unmatched_po_lines` | 200 | 422 | Some PO lines had no matching invoice line | Supplier didn't invoice everything; flag for the buyer |
|
||||||
|
| `unmatched_invoice_lines` | 200 | 422 | Some invoice lines had no matching PO line | Supplier invoiced extra items not on our PO; flag for the buyer |
|
||||||
|
| `qty_mismatch` | 200 | 422 | One or more lines: PO qty ≠ invoice qty | Flag the per-line `qty_delta` for the buyer |
|
||||||
|
| `cost_mismatch` | 200 | 422 | One or more lines: PO cost ≠ invoice unit_price (R1 tolerance) | Flag the per-line `cost_delta` for the buyer |
|
||||||
|
| `supplier_mismatch` | 200 | 422 | Invoice supplier name fuzzy-mismatches the PO's supplier | Strong sanity-check failure; likely wrong PO ref |
|
||||||
|
|
||||||
|
Dry-run mode always returns HTTP 200 — the verdict + `would_receive: true/false` flag tells you what would have happened. HTTP 404/409/422 only fire on the commit path (`confirm: true`). All non-`ready_to_receive` verdicts refuse to commit regardless of `confirm: true`; the per-line detail is in `report.line_comparisons[*].notes`.
|
||||||
|
|
||||||
|
### Filename convention
|
||||||
|
|
||||||
|
Attachments written by this tool follow the operator's existing convention (see PO26073109STK):
|
||||||
|
|
||||||
|
```
|
||||||
|
supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<supplier-slug>` strips common entity suffixes (PTY, LTD, CC, INC, LLC, CO), lowercases, and collapses non-alnum to `_`. PB appends its own random token at write time, so the on-disk name looks like `supplier_invoice_hpi_inserts_IN389384_2026-08-05_z7ho1rrzk9.jpg`. Use the `supplier_invoice_` prefix to filter invoices out of generic PO attachments:
|
||||||
|
|
||||||
|
```
|
||||||
|
stock_purchases.list({ filter: 'additional_attachments ~ "supplier_invoice_"' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Known bugs / quirks (smoke-tested 2026-08-04)
|
## Known bugs / quirks (smoke-tested 2026-08-04)
|
||||||
|
|
||||||
These are live issues on production. Verify they're still current before reporting them.
|
These are live issues on production. Verify they're still current before reporting them.
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 714 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 732 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 882 KiB |
|
|
@ -129,3 +129,4 @@ These came up but need a human answer before code changes:
|
||||||
|
|
||||||
- 2026-08-06 — `stock_purchases.add_attachment` shipped (commits `f89bbb3`, `dd57d29`). Adds a sibling to `purchase_orders.additional_attachments` plus the matching REST upload/delete routes and a UI card on `/stock-orders/[id]`. PB schema: new `stock_purchases.additional_attachments` file field (multi, maxSelect 10, any MIME).
|
- 2026-08-06 — `stock_purchases.add_attachment` shipped (commits `f89bbb3`, `dd57d29`). Adds a sibling to `purchase_orders.additional_attachments` plus the matching REST upload/delete routes and a UI card on `/stock-orders/[id]`. PB schema: new `stock_purchases.additional_attachments` file field (multi, maxSelect 10, any MIME).
|
||||||
- 2026-08-07 — Split-invoice stock-PO workflow shipped. 5 new MCP tools (`stock_purchase_items.update`, `stock_purchase_items.bulk_update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.mark_collected`, `stock_items.update`) and the underlying services (`stock-purchase-items-service.ts`, extended `stock-items-service.ts`). Closes the per-line write gap surfaced by the operator's split-delivery scenario (PO with multiple supplier invoices arriving on different days). `mark_collected` auto-flips the parent PO header to `received` when every line is fully collected. PB schema: new `stock_purchase_items.sku` / `unit` / `supplier_sku` text fields (migration `scripts/migrations/2026-08-07-stock-purchase-items-catalogue-fields.ts`, user applies via PB admin).
|
- 2026-08-07 — Split-invoice stock-PO workflow shipped. 5 new MCP tools (`stock_purchase_items.update`, `stock_purchase_items.bulk_update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.mark_collected`, `stock_items.update`) and the underlying services (`stock-purchase-items-service.ts`, extended `stock-items-service.ts`). Closes the per-line write gap surfaced by the operator's split-delivery scenario (PO with multiple supplier invoices arriving on different days). `mark_collected` auto-flips the parent PO header to `received` when every line is fully collected. PB schema: new `stock_purchase_items.sku` / `unit` / `supplier_sku` text fields (migration `scripts/migrations/2026-08-07-stock-purchase-items-catalogue-fields.ts`, user applies via PB admin).
|
||||||
|
- 2026-08-09 — Buyer-side supplier-invoice reconciliation shipped. New MCP tool `stock_purchases.match_and_receive_from_invoice` (single commit, no schema change). The agent parses a scanned supplier invoice with its native vision, passes the structured fields (`po_reference`, `invoice.{document_no,date,supplier_name?,lines[]}`, optional `attachment` bytes) plus a `confirm` flag. Deterministic line-by-line matching: `stock_item.sku` exact → `stock_purchase_item.supplier_sku` exact → description token overlap (≥80%). Verdict enum (`ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`) drives the HTTP status (200/404/409/422). Dry-run by default; `confirm=true` only commits when `verdict === "ready_to_receive"` — on success it attaches the invoice image using the `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>` naming convention (PB appends its random token at write time, matching the operator's existing `supplier_invoice_afriq_ina64721_2026_08_06_…` format on PO26073109STK), then calls `stock_purchases.receive` in the same round-trip. Closes the "AI checks my invoices for the buyer" workflow surfaced 2026-08-09. Implementation: new `src/lib/server/stock-purchase-invoice-matching-service.ts` (+ matching `.test.ts`), wired into `src/app/api/mcp/route.ts` (one new tool entry + one dispatch block + 4 dispatch tests). 1,654/1,657 vitest tests pass; the 3 failing tests are pre-existing (PocketBase SDK mock mismatch in `pocketbase-admin.test.ts`, 5s timeout in `quotes/items/[id]/attachments/upload/route.test.ts` — both reproducible on `main` without this change).
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ vi.mock("@/lib/server/stock-purchase-items-service", () => ({
|
||||||
markLineCollected: (...args: unknown[]) => markLineCollected(...args),
|
markLineCollected: (...args: unknown[]) => markLineCollected(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Buyer-side invoice reconciliation (added 2026-08-09).
|
||||||
|
const matchAndReceiveFromInvoiceMock = vi.fn();
|
||||||
|
vi.mock("@/lib/server/stock-purchase-invoice-matching-service", () => ({
|
||||||
|
matchAndReceiveFromInvoice: (...args: unknown[]) => matchAndReceiveFromInvoiceMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the catalogue service for stock_items.update.
|
// Mock the catalogue service for stock_items.update.
|
||||||
const updateStockItem = vi.fn();
|
const updateStockItem = vi.fn();
|
||||||
vi.mock("@/lib/server/stock-items-service", () => ({
|
vi.mock("@/lib/server/stock-items-service", () => ({
|
||||||
|
|
@ -111,6 +117,7 @@ describe("POST /api/mcp", () => {
|
||||||
assignDriver.mockReset();
|
assignDriver.mockReset();
|
||||||
markLineCollected.mockReset();
|
markLineCollected.mockReset();
|
||||||
updateStockItem.mockReset();
|
updateStockItem.mockReset();
|
||||||
|
matchAndReceiveFromInvoiceMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("401s without bearer token", async () => {
|
it("401s without bearer token", async () => {
|
||||||
|
|
@ -142,7 +149,7 @@ describe("POST /api/mcp", () => {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.jsonrpc).toBe("2.0");
|
expect(body.jsonrpc).toBe("2.0");
|
||||||
expect(body.id).toBe(1);
|
expect(body.id).toBe(1);
|
||||||
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
|
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
|
||||||
expect(toolNames).toEqual([
|
expect(toolNames).toEqual([
|
||||||
// Orders / quotes (existing)
|
// Orders / quotes (existing)
|
||||||
"orders.list",
|
"orders.list",
|
||||||
|
|
@ -156,6 +163,10 @@ describe("POST /api/mcp", () => {
|
||||||
"order_items.list",
|
"order_items.list",
|
||||||
"order_items.get",
|
"order_items.get",
|
||||||
"quotes.create_from_email",
|
"quotes.create_from_email",
|
||||||
|
// Quotes AI pipeline (added 2026-08-08 — internal agent worker)
|
||||||
|
"quotes.ingest_from_email",
|
||||||
|
"quotes.process_pending",
|
||||||
|
"quotes.re_run_ai",
|
||||||
"quotes.append_reply",
|
"quotes.append_reply",
|
||||||
"quotes.find_by_message_id",
|
"quotes.find_by_message_id",
|
||||||
"quotes.list",
|
"quotes.list",
|
||||||
|
|
@ -190,6 +201,8 @@ describe("POST /api/mcp", () => {
|
||||||
"quote_items.get",
|
"quote_items.get",
|
||||||
"quote_items.create",
|
"quote_items.create",
|
||||||
"quote_items.update",
|
"quote_items.update",
|
||||||
|
// Quote-item insights (added 2026-08-08)
|
||||||
|
"quote_items.populate_insights",
|
||||||
// Stock items + purchases (added 2026-07-30 — Hermes + n8n coverage)
|
// Stock items + purchases (added 2026-07-30 — Hermes + n8n coverage)
|
||||||
"stock_items.list",
|
"stock_items.list",
|
||||||
"stock_items.get",
|
"stock_items.get",
|
||||||
|
|
@ -206,6 +219,8 @@ describe("POST /api/mcp", () => {
|
||||||
"stock_purchases.send_email",
|
"stock_purchases.send_email",
|
||||||
"stock_purchases.retry_email",
|
"stock_purchases.retry_email",
|
||||||
"stock_purchases.add_attachment",
|
"stock_purchases.add_attachment",
|
||||||
|
// Buyer-side invoice reconciliation (added 2026-08-09)
|
||||||
|
"stock_purchases.match_and_receive_from_invoice",
|
||||||
// Per-line stock-purchase writes (added 2026-08-07)
|
// Per-line stock-purchase writes (added 2026-08-07)
|
||||||
"stock_purchase_items.update",
|
"stock_purchase_items.update",
|
||||||
"stock_purchase_items.bulk_update",
|
"stock_purchase_items.bulk_update",
|
||||||
|
|
@ -224,6 +239,9 @@ describe("POST /api/mcp", () => {
|
||||||
"stock.surplus_book",
|
"stock.surplus_book",
|
||||||
// AI insights rerun (added 2026-07-30)
|
// AI insights rerun (added 2026-07-30)
|
||||||
"ai_insights.rerun",
|
"ai_insights.rerun",
|
||||||
|
// AI settings (added 2026-08-08 — operator UI configures the model)
|
||||||
|
"ai_settings.get",
|
||||||
|
"ai_settings.update",
|
||||||
// Management CRUD (added 2026-06-28 — Hermes agent support)
|
// Management CRUD (added 2026-06-28 — Hermes agent support)
|
||||||
"companies.list",
|
"companies.list",
|
||||||
"companies.get",
|
"companies.get",
|
||||||
|
|
@ -490,6 +508,232 @@ describe("POST /api/mcp", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tools/call stock_purchases.match_and_receive_from_invoice dry-runs by default and returns the report", async () => {
|
||||||
|
// The handler delegates the entire matching + report build to the
|
||||||
|
// service helper. Confirm the call is shaped correctly (parsed
|
||||||
|
// invoice, dry-run flag, no attachment echo) and that the verdict
|
||||||
|
// drives a 200 response — even when would_receive is false.
|
||||||
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
mode: "dry_run",
|
||||||
|
would_receive: false,
|
||||||
|
attachment_filename: null,
|
||||||
|
report: {
|
||||||
|
verdict: "qty_mismatch",
|
||||||
|
po_id: "sp-1",
|
||||||
|
po_number: "PO26080501STK",
|
||||||
|
po_status: "sent",
|
||||||
|
po_supplier_id: "sup-1",
|
||||||
|
po_supplier_name: "HPI INSERTS",
|
||||||
|
invoice_supplier_name: "H P I INSERTS (PTY) LTD",
|
||||||
|
ordered_value: 1150,
|
||||||
|
received_value: 0,
|
||||||
|
unmatched_po_lines: [],
|
||||||
|
unmatched_invoice_lines: [],
|
||||||
|
line_comparisons: [
|
||||||
|
{
|
||||||
|
invoice_line_no: 1,
|
||||||
|
invoice: { description: "HPI RIVNUT TOOL 510 M3 - M12", quantity: 2, unit_price: 1150 },
|
||||||
|
po_line_id: "spl-1",
|
||||||
|
stock_item_id: "si-1",
|
||||||
|
match_method: "sku_exact",
|
||||||
|
qty_delta: -1,
|
||||||
|
cost_delta: 0,
|
||||||
|
notes: ["qty mismatch: PO=1, invoice=2"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
mcpRequest({
|
||||||
|
method: "tools/call",
|
||||||
|
id: 70,
|
||||||
|
params: {
|
||||||
|
name: "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
arguments: {
|
||||||
|
po_reference: "PO26080501STK",
|
||||||
|
invoice: {
|
||||||
|
document_no: "IN389384",
|
||||||
|
date: "2026-08-05",
|
||||||
|
supplier_name: "H P I INSERTS (PTY) LTD",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
code: "56020510",
|
||||||
|
description: "HPI RIVNUT TOOL 510 M3 - M12",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1150,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(matchAndReceiveFromInvoiceMock).toHaveBeenCalledWith({
|
||||||
|
po_reference: "PO26080501STK",
|
||||||
|
invoice: {
|
||||||
|
document_no: "IN389384",
|
||||||
|
date: "2026-08-05",
|
||||||
|
supplier_name: "H P I INSERTS (PTY) LTD",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
code: "56020510",
|
||||||
|
description: "HPI RIVNUT TOOL 510 M3 - M12",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1150,
|
||||||
|
line_total: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachment: undefined,
|
||||||
|
confirm: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
const data = JSON.parse(body.result.content[0].text);
|
||||||
|
expect(data.report.verdict).toBe("qty_mismatch");
|
||||||
|
expect(data.would_receive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 422 on a qty_mismatch when confirm=true", async () => {
|
||||||
|
// The handler must refuse to commit when the verdict is anything
|
||||||
|
// other than `ready_to_receive`. qty_mismatch → 422.
|
||||||
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
mode: "committed",
|
||||||
|
reason: "qty_mismatch",
|
||||||
|
message:
|
||||||
|
'Refused to commit — verdict is "qty_mismatch". Resolve the mismatches and call again.',
|
||||||
|
report: {
|
||||||
|
verdict: "qty_mismatch",
|
||||||
|
po_id: "sp-1",
|
||||||
|
po_number: "PO26080501STK",
|
||||||
|
po_status: "sent",
|
||||||
|
po_supplier_id: "sup-1",
|
||||||
|
po_supplier_name: "HPI INSERTS",
|
||||||
|
invoice_supplier_name: "HPI INSERTS",
|
||||||
|
ordered_value: 1150,
|
||||||
|
received_value: 0,
|
||||||
|
unmatched_po_lines: [],
|
||||||
|
unmatched_invoice_lines: [],
|
||||||
|
line_comparisons: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
mcpRequest({
|
||||||
|
method: "tools/call",
|
||||||
|
id: 71,
|
||||||
|
params: {
|
||||||
|
name: "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
arguments: {
|
||||||
|
po_reference: "PO26080501STK",
|
||||||
|
invoice: {
|
||||||
|
document_no: "IN389384",
|
||||||
|
date: "2026-08-05",
|
||||||
|
lines: [
|
||||||
|
{ code: "56020510", description: "x", quantity: 2, unit_price: 1150 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confirm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
const body = await res.json();
|
||||||
|
const data = JSON.parse(body.result.content[0].text);
|
||||||
|
expect(data.reason).toBe("qty_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 200 + receive echo on success", async () => {
|
||||||
|
matchAndReceiveFromInvoiceMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
mode: "committed",
|
||||||
|
report: {
|
||||||
|
verdict: "ready_to_receive",
|
||||||
|
po_id: "sp-1",
|
||||||
|
po_number: "PO26080501STK",
|
||||||
|
po_status: "received",
|
||||||
|
po_supplier_id: "sup-1",
|
||||||
|
po_supplier_name: "HPI INSERTS",
|
||||||
|
invoice_supplier_name: "HPI INSERTS",
|
||||||
|
ordered_value: 1150,
|
||||||
|
received_value: 1150,
|
||||||
|
unmatched_po_lines: [],
|
||||||
|
unmatched_invoice_lines: [],
|
||||||
|
line_comparisons: [],
|
||||||
|
},
|
||||||
|
receive: {
|
||||||
|
purchase_id: "sp-1",
|
||||||
|
purchase_number: "PO26080501STK",
|
||||||
|
status: "received",
|
||||||
|
received_value: 1150,
|
||||||
|
ordered_value: 1150,
|
||||||
|
lines_booked: 1,
|
||||||
|
},
|
||||||
|
attachment: {
|
||||||
|
filename: "supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg",
|
||||||
|
added: ["supplier_invoice_hpi_inserts_in389384_2026-08-05_z7ho1rrzk9.jpg"],
|
||||||
|
attachments: ["supplier_invoice_hpi_inserts_in389384_2026-08-05_z7ho1rrzk9.jpg"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
mcpRequest({
|
||||||
|
method: "tools/call",
|
||||||
|
id: 72,
|
||||||
|
params: {
|
||||||
|
name: "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
arguments: {
|
||||||
|
po_reference: "PO26080501STK",
|
||||||
|
invoice: {
|
||||||
|
document_no: "IN389384",
|
||||||
|
date: "2026-08-05",
|
||||||
|
lines: [
|
||||||
|
{ code: "56020510", description: "HPI RIVNUT TOOL 510 M3 - M12", quantity: 1, unit_price: 1150 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachment: {
|
||||||
|
filename: "IN389384.jpg",
|
||||||
|
content_type: "image/jpeg",
|
||||||
|
base64: Buffer.from("fake-jpeg-bytes").toString("base64"),
|
||||||
|
},
|
||||||
|
confirm: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
const data = JSON.parse(body.result.content[0].text);
|
||||||
|
expect(data.ok).toBe(true);
|
||||||
|
expect(data.mode).toBe("committed");
|
||||||
|
expect(data.receive.lines_booked).toBe(1);
|
||||||
|
expect(data.attachment.added).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tools/call stock_purchases.match_and_receive_from_invoice returns 400 when po_reference is missing", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
mcpRequest({
|
||||||
|
method: "tools/call",
|
||||||
|
id: 73,
|
||||||
|
params: {
|
||||||
|
name: "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
arguments: {
|
||||||
|
invoice: { document_no: "X", date: "2026-08-05", lines: [{ description: "x", quantity: 1, unit_price: 1 }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
it("tools/call stock_purchase_items.update delegates to the service", async () => {
|
it("tools/call stock_purchase_items.update delegates to the service", async () => {
|
||||||
updateStockPurchaseItem.mockResolvedValueOnce({
|
updateStockPurchaseItem.mockResolvedValueOnce({
|
||||||
item: { id: "spi-1", sku: "NEW-SKU" },
|
item: { id: "spi-1", sku: "NEW-SKU" },
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
type CreateStockPurchaseInput,
|
type CreateStockPurchaseInput,
|
||||||
type StockPurchaseStatus,
|
type StockPurchaseStatus,
|
||||||
} from "@/lib/server/stock-purchases-service";
|
} from "@/lib/server/stock-purchases-service";
|
||||||
|
import { matchAndReceiveFromInvoice } from "@/lib/server/stock-purchase-invoice-matching-service";
|
||||||
import { generatePoForGroup, markGroupReady, listBuyerTasks } from "@/lib/server/buyer-tasks-service";
|
import { generatePoForGroup, markGroupReady, listBuyerTasks } from "@/lib/server/buyer-tasks-service";
|
||||||
import {
|
import {
|
||||||
populateQuoteItemInsights,
|
populateQuoteItemInsights,
|
||||||
|
|
@ -1268,21 +1269,90 @@ const tools = [
|
||||||
properties: { id: { type: "string" } },
|
properties: { id: { type: "string" } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "stock_purchases.add_attachment",
|
name: "stock_purchases.add_attachment",
|
||||||
description:
|
description:
|
||||||
"Upload a single free-form attachment (supplier invoice, packing slip, photo of damaged stock) to an existing stock purchase. The file is appended to `stock_purchases.additional_attachments`. Call once per file. Max 25 MB per file. The versioned-PDF `documents` field is intentionally NOT touched.",
|
"Upload a single free-form attachment (supplier invoice, packing slip, photo of damaged stock) to an existing stock purchase. The file is appended to `stock_purchases.additional_attachments`. Call once per file. Max 25 MB per file. The versioned-PDF `documents` field is intentionally NOT touched.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
"type": "object",
|
||||||
required: ["purchase_id", "filename", "base64"],
|
"required": ["purchase_id", "filename", "base64"],
|
||||||
properties: {
|
properties: {
|
||||||
purchase_id: {
|
purchase_id: {
|
||||||
type: "string",
|
"type": "string",
|
||||||
description: "15-char PocketBase stock_purchase id",
|
"description": "15-char PocketBase stock_purchase id",
|
||||||
},
|
},
|
||||||
|
filename: { "type": "string" },
|
||||||
|
content_type: { "type": "string" },
|
||||||
|
size: { "type": "number" },
|
||||||
|
base64: {
|
||||||
|
"type": "string",
|
||||||
|
"description":
|
||||||
|
"Raw file bytes encoded as base64 (no `data:` URL prefix).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stock_purchases.match_and_receive_from_invoice",
|
||||||
|
description:
|
||||||
|
"Buyer-side reconciliation: take a parsed supplier invoice and the matching PO number, run deterministic line-by-line matching (sku → supplier_sku → description overlap), and either dry-run or commit the receive. The agent parses the invoice image with its native vision capability and passes the structured fields in (`invoice.document_no`, `invoice.date`, `invoice.supplier_name?`, `invoice.lines[]`). Filename convention: `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>` — PocketBase appends a random token, so on-disk the name looks like `supplier_invoice_hpi_inserts_IN389384_2026-08-05_z7ho1rrzk9.jpg`. Verdict enum: `ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`. With `confirm=false` (default), the tool only reports — it never writes. With `confirm=true`, the receive is **blocked** unless `verdict === \"ready_to_receive\"`; on success it attaches the invoice (if `attachment` was supplied) and then calls `stock_purchases.receive` in one round-trip.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["po_reference", "invoice"],
|
||||||
|
properties: {
|
||||||
|
po_reference: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"The \"Your Ref\" on the invoice — matches `stock_purchases.purchase_number` (e.g. `PO26080501STK`). Case-insensitive; whitespace-trimmed.",
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
type: "object",
|
||||||
|
required: ["document_no", "date", "lines"],
|
||||||
|
properties: {
|
||||||
|
document_no: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Supplier's invoice number (the \"Document No\" on the printed form).",
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Invoice date in `YYYY-MM-DD`. The agent should convert from the supplier's local format (e.g. `05/08/26` → `2026-08-05`).",
|
||||||
|
},
|
||||||
|
supplier_name: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Optional supplier name as printed on the invoice — used as a soft cross-check against the PO's supplier.",
|
||||||
|
},
|
||||||
|
lines: {
|
||||||
|
type: "array",
|
||||||
|
minItems: 1,
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
required: ["description", "quantity", "unit_price"],
|
||||||
|
properties: {
|
||||||
|
code: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Supplier's SKU / catalogue code (e.g. `56020510`). Optional but strongly preferred — enables the exact-match path.",
|
||||||
|
},
|
||||||
|
description: { type: "string" },
|
||||||
|
quantity: { type: "number" },
|
||||||
|
unit_price: { type: "number" },
|
||||||
|
line_total: { type: "number" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attachment: {
|
||||||
|
type: "object",
|
||||||
|
description:
|
||||||
|
"Optional raw invoice bytes to attach to the PO with the `supplier_invoice_…` filename convention. PB will append its own random token at write time.",
|
||||||
|
required: ["filename", "content_type", "base64"],
|
||||||
|
properties: {
|
||||||
filename: { type: "string" },
|
filename: { type: "string" },
|
||||||
content_type: { type: "string" },
|
content_type: { type: "string" },
|
||||||
size: { type: "number" },
|
|
||||||
base64: {
|
base64: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
|
|
@ -1290,6 +1360,13 @@ const tools = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
confirm: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"`false` (default) — dry run, returns the report only. `true` — commit, requires `verdict === \"ready_to_receive\"`, attaches the invoice (if any) then calls `stock_purchases.receive`.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "stock_purchase_items.update",
|
name: "stock_purchase_items.update",
|
||||||
|
|
@ -3200,6 +3277,95 @@ export async function POST(req: NextRequest) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toolName === "stock_purchases.match_and_receive_from_invoice") {
|
||||||
|
// Buyer-side invoice reconciliation. The agent passes already-parsed
|
||||||
|
// invoice fields (it does the OCR with its native vision capability);
|
||||||
|
// the tool does the matching deterministically against PB and either
|
||||||
|
// dry-runs or commits (attach + receive) in one round-trip.
|
||||||
|
const poReference = typeof args.po_reference === "string"
|
||||||
|
? args.po_reference
|
||||||
|
: "";
|
||||||
|
const invoiceRaw = args.invoice;
|
||||||
|
if (!poReference || !invoiceRaw || typeof invoiceRaw !== "object") {
|
||||||
|
return jsonRpcOk(
|
||||||
|
body.id,
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
reason: "po_not_found",
|
||||||
|
message:
|
||||||
|
"Missing po_reference or invoice for stock_purchases.match_and_receive_from_invoice",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const invoice = invoiceRaw as Record<string, unknown>;
|
||||||
|
const attachmentRaw = args.attachment;
|
||||||
|
const attachment =
|
||||||
|
attachmentRaw && typeof attachmentRaw === "object"
|
||||||
|
? {
|
||||||
|
filename: String(
|
||||||
|
(attachmentRaw as Record<string, unknown>).filename ?? "",
|
||||||
|
),
|
||||||
|
content_type: String(
|
||||||
|
(attachmentRaw as Record<string, unknown>).content_type ?? "",
|
||||||
|
),
|
||||||
|
base64: String(
|
||||||
|
(attachmentRaw as Record<string, unknown>).base64 ?? "",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const confirm = args.confirm === true;
|
||||||
|
|
||||||
|
const result = await matchAndReceiveFromInvoice({
|
||||||
|
po_reference: poReference,
|
||||||
|
invoice: {
|
||||||
|
document_no: String(invoice.document_no ?? ""),
|
||||||
|
date: String(invoice.date ?? ""),
|
||||||
|
supplier_name:
|
||||||
|
typeof invoice.supplier_name === "string"
|
||||||
|
? invoice.supplier_name
|
||||||
|
: undefined,
|
||||||
|
lines: Array.isArray(invoice.lines)
|
||||||
|
? (invoice.lines as Array<Record<string, unknown>>).map((l) => ({
|
||||||
|
code:
|
||||||
|
typeof l.code === "string" ? l.code : undefined,
|
||||||
|
description: String(l.description ?? ""),
|
||||||
|
quantity: Number(l.quantity ?? 0),
|
||||||
|
unit_price: Number(l.unit_price ?? 0),
|
||||||
|
line_total:
|
||||||
|
typeof l.line_total === "number"
|
||||||
|
? l.line_total
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
attachment:
|
||||||
|
attachment && attachment.filename && attachment.base64
|
||||||
|
? attachment
|
||||||
|
: undefined,
|
||||||
|
confirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verdict → HTTP status mapping (matches existing tool conventions):
|
||||||
|
// ready_to_receive → 200 (dry run) / 200 (committed)
|
||||||
|
// po_not_found → 404
|
||||||
|
// wrong_status → 409
|
||||||
|
// supplier_mismatch → 422
|
||||||
|
// unmatched_* / qty_ / cost_ → 422
|
||||||
|
let status = 200;
|
||||||
|
if (!result.ok) {
|
||||||
|
status =
|
||||||
|
result.reason === "po_not_found"
|
||||||
|
? 404
|
||||||
|
: result.reason === "wrong_status"
|
||||||
|
? 409
|
||||||
|
: 422;
|
||||||
|
} else if (result.mode === "committed") {
|
||||||
|
status = 200;
|
||||||
|
}
|
||||||
|
return jsonRpcOk(body.id, result, status);
|
||||||
|
}
|
||||||
|
|
||||||
if (toolName === "stock_purchases.next_number") {
|
if (toolName === "stock_purchases.next_number") {
|
||||||
const purchase_number = await generateStockPurchaseNumber(pb);
|
const purchase_number = await generateStockPurchaseNumber(pb);
|
||||||
return jsonRpcOk(body.id, { purchase_number });
|
return jsonRpcOk(body.id, { purchase_number });
|
||||||
|
|
|
||||||
148
src/lib/server/stock-purchase-invoice-matching-service.test.ts
Normal file
148
src/lib/server/stock-purchase-invoice-matching-service.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
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 {
|
||||||
|
buildSupplierInvoiceFilename,
|
||||||
|
descriptionOverlap,
|
||||||
|
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("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);
|
||||||
|
});
|
||||||
|
});
|
||||||
736
src/lib/server/stock-purchase-invoice-matching-service.ts
Normal file
736
src/lib/server/stock-purchase-invoice-matching-service.ts
Normal file
|
|
@ -0,0 +1,736 @@
|
||||||
|
/**
|
||||||
|
* Stock purchase invoice matching — agent-side reconciliation.
|
||||||
|
*
|
||||||
|
* Why this exists
|
||||||
|
* ───────────────
|
||||||
|
* `stock_purchases.receive` already does the actual bookkeeping (status
|
||||||
|
* flip, ledger rows, stock_items.current_qty bump). What's *missing*
|
||||||
|
* for a buyer is the workflow that takes a **scanned supplier invoice
|
||||||
|
* image** and decides whether the PO it refers to can be received
|
||||||
|
* safely. That's the bridge between "the supplier posted an invoice in
|
||||||
|
* the WhatsApp group" and "press receive".
|
||||||
|
*
|
||||||
|
* The two existing flows this augments:
|
||||||
|
*
|
||||||
|
* - `stock_purchases.receive` (`stock-purchases-service.ts`) — the
|
||||||
|
* mechanic. Idempotent on `status="received"`.
|
||||||
|
* - `bookStockFromInvoice` (`stock-booking-service.ts`) — the
|
||||||
|
* accountant's later commit. This module deliberately does **not**
|
||||||
|
* touch it; the buyer-side receive happens first, then the
|
||||||
|
* accountant books it for payment-cycle reasoning.
|
||||||
|
*
|
||||||
|
* Where OCR happens
|
||||||
|
* ─────────────────
|
||||||
|
* The MCP server stays stateless and PB-only. The agent (an LLM) reads
|
||||||
|
* the invoice image with its native vision capability and passes the
|
||||||
|
* parsed fields in. We then do the matching, validation, and decision
|
||||||
|
* deterministically against PocketBase.
|
||||||
|
*
|
||||||
|
* Why deterministic matching matters here
|
||||||
|
* ───────────────────────────────────────
|
||||||
|
* A buyer who lets an LLM decide "looks close enough, ship it" will
|
||||||
|
* book the wrong stock. So the matching algorithm is rule-based:
|
||||||
|
*
|
||||||
|
* 1. Resolve the PO by `purchase_number` (the "Your Ref" field on the
|
||||||
|
* invoice).
|
||||||
|
* 2. Resolve the supplier and compare it to the invoice's
|
||||||
|
* `supplier_name` (sanity check, not a hard block).
|
||||||
|
* 3. For each invoice line, try to find a matching PO line by:
|
||||||
|
* a. `stock_item.sku` exact match to `invoice.code`
|
||||||
|
* b. `stock_purchase_item.supplier_sku` exact match to
|
||||||
|
* `invoice.code`
|
||||||
|
* c. Description token overlap (≥80% of invoice tokens appear
|
||||||
|
* in the PO line description)
|
||||||
|
* 4. Verify qty + cost match (within 1c floating-point tolerance).
|
||||||
|
*
|
||||||
|
* If any of those fail, the verdict is one of `po_not_found`,
|
||||||
|
* `wrong_status`, `supplier_mismatch`, `unmatched_po_lines`,
|
||||||
|
* `unmatched_invoice_lines`, `qty_mismatch`, `cost_mismatch` — and
|
||||||
|
* the receive is **blocked** regardless of `confirm: true`. The agent
|
||||||
|
* surfaces the verdict + per-line reasons to the buyer, who decides
|
||||||
|
* manually whether to fix the PO or override.
|
||||||
|
*
|
||||||
|
* The filename convention
|
||||||
|
* ───────────────────────
|
||||||
|
* Attachments uploaded through this tool are named
|
||||||
|
* `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>`
|
||||||
|
* (PB appends its own random token at write time, so the on-disk name
|
||||||
|
* looks like `..._z7ho1rrzk9.jpg`). The `supplier_invoice_` prefix is
|
||||||
|
* what the operator's PB filter uses to surface invoice attachments
|
||||||
|
* distinctly from generic PO attachments — see the AFRIQ example on
|
||||||
|
* PO26073109STK.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||||
|
import {
|
||||||
|
addStockPurchaseAttachment,
|
||||||
|
receiveStockPurchase,
|
||||||
|
} from "@/lib/server/stock-purchases-service";
|
||||||
|
|
||||||
|
// ─── Constants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STOCK_PURCHASES = "stock_purchases";
|
||||||
|
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
|
||||||
|
const STOCK_ITEMS = "stock_items";
|
||||||
|
|
||||||
|
// Reused here so the supplier-invoice prefix lives in exactly one place.
|
||||||
|
export const SUPPLIER_INVOICE_FILENAME_PREFIX = "supplier_invoice_";
|
||||||
|
|
||||||
|
// Re-exported so route.ts can echo them in error messages without
|
||||||
|
// re-hardcoding.
|
||||||
|
export const MATCH_TOLERANCE = 0.01;
|
||||||
|
export const DESCRIPTION_OVERLAP_THRESHOLD = 0.8;
|
||||||
|
|
||||||
|
// ─── Types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A single line on the supplier's invoice, parsed by the agent's vision. */
|
||||||
|
export type InvoiceLine = {
|
||||||
|
/** Supplier's SKU / catalogue code (e.g. "56020510"). */
|
||||||
|
code?: string;
|
||||||
|
/** Free-text description as printed on the invoice. */
|
||||||
|
description: string;
|
||||||
|
/** Number of units on this line. */
|
||||||
|
quantity: number;
|
||||||
|
/** Nett unit price as printed, in ZAR. */
|
||||||
|
unit_price: number;
|
||||||
|
/** Optional line total — only used for cross-check if present. */
|
||||||
|
line_total?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parsed invoice header + lines, supplied by the calling agent. */
|
||||||
|
export type ParsedInvoice = {
|
||||||
|
/** Supplier's invoice number (the "Document No" on the printed form). */
|
||||||
|
document_no: string;
|
||||||
|
/** Invoice date in `YYYY-MM-DD` (already converted from `DD/MM/YY`). */
|
||||||
|
date: string;
|
||||||
|
/** Optional supplier name as printed on the invoice — for sanity. */
|
||||||
|
supplier_name?: string;
|
||||||
|
/** Line items in document order. */
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Optional image bytes to attach with the supplier-invoice naming. */
|
||||||
|
export type InvoiceAttachment = {
|
||||||
|
filename: string;
|
||||||
|
content_type: string;
|
||||||
|
base64: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchAndReceiveInput = {
|
||||||
|
/**
|
||||||
|
* The "Your Ref" on the invoice — i.e. the `stock_purchases.purchase_number`
|
||||||
|
* (e.g. "PO26080501STK"). Case-insensitive; whitespace-trimmed.
|
||||||
|
*/
|
||||||
|
po_reference: string;
|
||||||
|
/** Parsed invoice content from the agent's vision pass. */
|
||||||
|
invoice: ParsedInvoice;
|
||||||
|
/**
|
||||||
|
* If provided, the raw invoice bytes get uploaded as an attachment
|
||||||
|
* with the `supplier_invoice_…` filename convention. Skip this when
|
||||||
|
* the operator only wants the matching report.
|
||||||
|
*/
|
||||||
|
attachment?: InvoiceAttachment;
|
||||||
|
/**
|
||||||
|
* `false` (default) — dry run. Returns the matching report and
|
||||||
|
* `would_receive: true/false`, but does NOT touch PB.
|
||||||
|
* `true` — commit. Only proceeds when verdict is `ready_to_receive`.
|
||||||
|
*/
|
||||||
|
confirm?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Verdict strings, stable for branching on the agent side. */
|
||||||
|
export type MatchVerdict =
|
||||||
|
| "ready_to_receive"
|
||||||
|
| "po_not_found"
|
||||||
|
| "wrong_status"
|
||||||
|
| "supplier_mismatch"
|
||||||
|
| "unmatched_po_lines"
|
||||||
|
| "unmatched_invoice_lines"
|
||||||
|
| "qty_mismatch"
|
||||||
|
| "cost_mismatch";
|
||||||
|
|
||||||
|
export type LineComparison = {
|
||||||
|
/** Invoice line index (1-based, for human display). */
|
||||||
|
invoice_line_no: number;
|
||||||
|
/** What the invoice said. */
|
||||||
|
invoice: InvoiceLine;
|
||||||
|
/** Matching PO line id, or null if no match. */
|
||||||
|
po_line_id: string | null;
|
||||||
|
/** Matching stock_item id (via the PO line), or null. */
|
||||||
|
stock_item_id: string | null;
|
||||||
|
/** What matching strategy produced this pairing. */
|
||||||
|
match_method: "sku_exact" | "supplier_sku_exact" | "description_overlap" | "none";
|
||||||
|
/** Difference between PO line `quantity` and invoice `quantity` (PO - invoice). */
|
||||||
|
qty_delta: number | null;
|
||||||
|
/** Difference between PO line `cost_price` and invoice `unit_price`. */
|
||||||
|
cost_delta: number | null;
|
||||||
|
/** Human-readable notes for the buyer. */
|
||||||
|
notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchReport = {
|
||||||
|
verdict: MatchVerdict;
|
||||||
|
po_id: string | null;
|
||||||
|
po_number: string | null;
|
||||||
|
po_status: string | null;
|
||||||
|
po_supplier_id: string | null;
|
||||||
|
po_supplier_name: string | null;
|
||||||
|
invoice_supplier_name: string | null;
|
||||||
|
ordered_value: number;
|
||||||
|
received_value: number;
|
||||||
|
/** PO lines that could NOT be paired with any invoice line. */
|
||||||
|
unmatched_po_lines: Array<{ id: string; description: string; quantity: number; cost_price: number }>;
|
||||||
|
/** Invoice lines that could NOT be paired with any PO line. */
|
||||||
|
unmatched_invoice_lines: Array<{ line_no: number; description: string; quantity: number }>;
|
||||||
|
/** Per-line reconciliation (1 entry per invoice line). */
|
||||||
|
line_comparisons: LineComparison[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchAndReceiveDryRun = {
|
||||||
|
ok: true;
|
||||||
|
mode: "dry_run";
|
||||||
|
report: MatchReport;
|
||||||
|
/** `true` if a `confirm: true` call would have proceeded. */
|
||||||
|
would_receive: boolean;
|
||||||
|
/** Filename the attachment *would* be uploaded as, if any was supplied. */
|
||||||
|
attachment_filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchAndReceiveCommitted = {
|
||||||
|
ok: true;
|
||||||
|
mode: "committed";
|
||||||
|
report: MatchReport;
|
||||||
|
/** Echo of the underlying `receiveStockPurchase` payload. */
|
||||||
|
receive: {
|
||||||
|
purchase_id: string;
|
||||||
|
purchase_number: string;
|
||||||
|
status: string;
|
||||||
|
received_value: number;
|
||||||
|
ordered_value: number;
|
||||||
|
lines_booked: number;
|
||||||
|
};
|
||||||
|
/** Echo of the underlying attachment call (or null if no attachment was supplied). */
|
||||||
|
attachment: {
|
||||||
|
filename: string;
|
||||||
|
added: string[];
|
||||||
|
attachments: string[];
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchAndReceiveBlocked = {
|
||||||
|
ok: false;
|
||||||
|
mode: "committed";
|
||||||
|
reason: MatchVerdict;
|
||||||
|
message: string;
|
||||||
|
report: MatchReport;
|
||||||
|
/** Echo of any partial work — e.g. the attachment was uploaded but the receive was blocked. */
|
||||||
|
partial?: {
|
||||||
|
attachment_added: string[] | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatchAndReceiveResult =
|
||||||
|
| MatchAndReceiveDryRun
|
||||||
|
| MatchAndReceiveCommitted
|
||||||
|
| MatchAndReceiveBlocked;
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Slugify a supplier name for the filename:
|
||||||
|
* - lowercase
|
||||||
|
* - strip common entity suffixes (PTY, LTD, CC, INC, LLC, CO)
|
||||||
|
* - non-alnum → `_`, collapse runs of `_`, trim
|
||||||
|
* - capped at 32 chars to keep filenames sane
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* "HPI INSERTS" → "hpi_inserts"
|
||||||
|
* "AFRIQ LUBE AND SERVICES CC" → "afriq_lube_and_services"
|
||||||
|
*/
|
||||||
|
export function slugifySupplierName(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b(pty|ltd|cc|inc|llc|co)\b/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slugify an invoice number: uppercase, non-alnum → `_`. */
|
||||||
|
export function slugifyInvoiceNumber(num: string): string {
|
||||||
|
return num
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose the canonical supplier-invoice filename. PB will append its
|
||||||
|
* own random token on write, so this is the prefix only. */
|
||||||
|
export function buildSupplierInvoiceFilename(
|
||||||
|
supplierName: string,
|
||||||
|
invoiceNumber: string,
|
||||||
|
isoDate: string,
|
||||||
|
originalExt: string,
|
||||||
|
): string {
|
||||||
|
const ext = originalExt.replace(/^\.+/, "").toLowerCase() || "jpg";
|
||||||
|
const safeDate = isoDate.replace(/[^0-9-]/g, "");
|
||||||
|
return `${SUPPLIER_INVOICE_FILENAME_PREFIX}${slugifySupplierName(supplierName)}_${slugifyInvoiceNumber(invoiceNumber)}_${safeDate}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise two supplier names for a fuzzy equality check.
|
||||||
|
* - lowercase, collapse whitespace, strip "PTY LTD", "CC", "INC", etc.
|
||||||
|
* - returns true when the trimmed form contains ≥80% of the shorter
|
||||||
|
* name's tokens (case-insensitive). Cheap, no library, no LLM. */
|
||||||
|
export function supplierNamesMatch(a: string, b: string): boolean {
|
||||||
|
const strip = (s: string): string =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b(pty\s*ltd|pty|ltd|cc|inc|llc|co\b|company)\b/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
const ta = strip(a);
|
||||||
|
const tb = strip(b);
|
||||||
|
if (!ta || !tb) return false;
|
||||||
|
if (ta === tb) return true;
|
||||||
|
const aTokens = new Set(ta.split(" "));
|
||||||
|
const bTokens = new Set(tb.split(" "));
|
||||||
|
const shorter = aTokens.size <= bTokens.size ? aTokens : bTokens;
|
||||||
|
const longer = aTokens.size <= bTokens.size ? bTokens : aTokens;
|
||||||
|
let hits = 0;
|
||||||
|
for (const t of shorter) if (longer.has(t)) hits++;
|
||||||
|
return hits / shorter.size >= DESCRIPTION_OVERLAP_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token overlap fraction: how much of `b`'s tokens appear in `a`.
|
||||||
|
* Used to fuzzy-match invoice descriptions to PO line descriptions. */
|
||||||
|
export function descriptionOverlap(a: string, b: string): number {
|
||||||
|
const norm = (s: string): string[] =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.split(" ")
|
||||||
|
.filter((t) => t.length >= 2); // drop noise ("1", "2" etc.)
|
||||||
|
const aTokens = norm(a);
|
||||||
|
const bTokens = norm(b);
|
||||||
|
if (aTokens.length === 0 || bTokens.length === 0) return 0;
|
||||||
|
const setA = new Set(aTokens);
|
||||||
|
const setB = new Set(bTokens);
|
||||||
|
let hits = 0;
|
||||||
|
for (const t of setB) if (setA.has(t)) hits++;
|
||||||
|
return hits / setB.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round a number to 2dp to absorb float drift. */
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main entry point ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function matchAndReceiveFromInvoice(
|
||||||
|
input: MatchAndReceiveInput,
|
||||||
|
): Promise<MatchAndReceiveResult> {
|
||||||
|
const pb = await getAdminPocketBase();
|
||||||
|
const poRef = String(input.po_reference ?? "").trim();
|
||||||
|
const dryRun = input.confirm !== true;
|
||||||
|
|
||||||
|
if (!poRef) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mode: "committed",
|
||||||
|
reason: "po_not_found",
|
||||||
|
message: "po_reference is required",
|
||||||
|
report: emptyReport(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Resolve PO by purchase_number ─────────────────────────────────
|
||||||
|
let purchase: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
const page = await pb
|
||||||
|
.collection(STOCK_PURCHASES)
|
||||||
|
.getList(1, 1, {
|
||||||
|
filter: `purchase_number = "${poRef.replace(/"/g, '\\"')}"`,
|
||||||
|
});
|
||||||
|
purchase = (page.items[0] as Record<string, unknown>) ?? null;
|
||||||
|
} catch {
|
||||||
|
purchase = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
const report: MatchReport = {
|
||||||
|
...emptyReport(input),
|
||||||
|
verdict: "po_not_found",
|
||||||
|
po_number: poRef,
|
||||||
|
};
|
||||||
|
return wrap(dryRun, report, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poId = String(purchase.id);
|
||||||
|
const poNumber = String(purchase.purchase_number ?? "");
|
||||||
|
const poStatus = String(purchase.status ?? "");
|
||||||
|
const poSupplierId = String(purchase.supplier ?? "");
|
||||||
|
const orderedValue = Number(purchase.ordered_value ?? 0);
|
||||||
|
const receivedValue = Number(purchase.received_value ?? 0);
|
||||||
|
|
||||||
|
// ── 2. Load supplier name (for cross-check) ─────────────────────────
|
||||||
|
let poSupplierName = "";
|
||||||
|
if (poSupplierId) {
|
||||||
|
try {
|
||||||
|
const sup = (await pb.collection("suppliers").getOne(poSupplierId)) as {
|
||||||
|
suppliername?: string;
|
||||||
|
};
|
||||||
|
poSupplierName = sup.suppliername ?? "";
|
||||||
|
} catch {
|
||||||
|
// ignore — leave empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Load PO lines + each one's stock_item.sku ────────────────────
|
||||||
|
const rawLines = (await pb
|
||||||
|
.collection(STOCK_PURCHASE_ITEMS)
|
||||||
|
.getFullList({
|
||||||
|
filter: `purchase = "${poId}"`,
|
||||||
|
sort: "created",
|
||||||
|
})) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
type ResolvedPoLine = {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
cost_price: number;
|
||||||
|
stock_item_id: string | null;
|
||||||
|
stock_item_sku: string;
|
||||||
|
supplier_sku: string;
|
||||||
|
};
|
||||||
|
const poLines: ResolvedPoLine[] = [];
|
||||||
|
for (const ln of rawLines) {
|
||||||
|
const stockItemId = (ln.stock_item as string | undefined) ?? null;
|
||||||
|
let stockItemSku = "";
|
||||||
|
if (stockItemId) {
|
||||||
|
try {
|
||||||
|
const si = (await pb.collection(STOCK_ITEMS).getOne(stockItemId)) as {
|
||||||
|
sku?: string;
|
||||||
|
};
|
||||||
|
stockItemSku = si.sku ?? "";
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poLines.push({
|
||||||
|
id: String(ln.id),
|
||||||
|
description: String(ln.description ?? ""),
|
||||||
|
quantity: Number(ln.quantity ?? 0),
|
||||||
|
cost_price: Number(ln.cost_price ?? 0),
|
||||||
|
stock_item_id: stockItemId,
|
||||||
|
stock_item_sku: stockItemSku,
|
||||||
|
supplier_sku: String(ln.supplier_sku ?? ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Pre-flight: PO status is checked later (after matching) ────
|
||||||
|
//
|
||||||
|
// We don't short-circuit on wrong_status here. Running the matching
|
||||||
|
// algorithm anyway means the buyer sees "this invoice WOULD have
|
||||||
|
// matched PO X perfectly, but PO X is already received — likely a
|
||||||
|
// duplicate invoice you can ignore or chase." Without that info the
|
||||||
|
// report is opaque.
|
||||||
|
// The verdict-finalisation step at the bottom sticks `wrong_status`
|
||||||
|
// when the PO isn't in `sent`, which is the actual blocker for
|
||||||
|
// committing.
|
||||||
|
|
||||||
|
// ── 5. Supplier cross-check (soft — surfaces warning but doesn't block) ─
|
||||||
|
let supplierMismatch = false;
|
||||||
|
if (input.invoice.supplier_name) {
|
||||||
|
if (!supplierNamesMatch(poSupplierName, input.invoice.supplier_name)) {
|
||||||
|
supplierMismatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Match invoice lines to PO lines ──────────────────────────────
|
||||||
|
//
|
||||||
|
// We run the matching algorithm even when the PO is in wrong_status,
|
||||||
|
// so the buyer can see "this invoice WOULD have matched PO26073109STK
|
||||||
|
// perfectly, but the PO is already received — likely a duplicate."
|
||||||
|
// Without the line comparisons, the report is opaque on a duplicate.
|
||||||
|
const invoiceLines = input.invoice.lines ?? [];
|
||||||
|
const usedPoIds = new Set<string>();
|
||||||
|
const comparisons: LineComparison[] = [];
|
||||||
|
const unmatchedPoLines: MatchReport["unmatched_po_lines"] = [];
|
||||||
|
const unmatchedInvoiceLines: MatchReport["unmatched_invoice_lines"] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < invoiceLines.length; i++) {
|
||||||
|
const inv = invoiceLines[i];
|
||||||
|
const invCode = (inv.code ?? "").trim();
|
||||||
|
const invDesc = (inv.description ?? "").trim();
|
||||||
|
|
||||||
|
let matchedId: string | null = null;
|
||||||
|
let matchedMethod: LineComparison["match_method"] = "none";
|
||||||
|
let matchedPo: ResolvedPoLine | null = null;
|
||||||
|
|
||||||
|
// a. stock_item.sku exact match
|
||||||
|
if (invCode) {
|
||||||
|
for (const p of poLines) {
|
||||||
|
if (usedPoIds.has(p.id)) continue;
|
||||||
|
if (p.stock_item_sku && p.stock_item_sku.toLowerCase() === invCode.toLowerCase()) {
|
||||||
|
matchedPo = p;
|
||||||
|
matchedMethod = "sku_exact";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// b. supplier_sku exact match
|
||||||
|
if (!matchedPo && invCode) {
|
||||||
|
for (const p of poLines) {
|
||||||
|
if (usedPoIds.has(p.id)) continue;
|
||||||
|
if (p.supplier_sku && p.supplier_sku.toLowerCase() === invCode.toLowerCase()) {
|
||||||
|
matchedPo = p;
|
||||||
|
matchedMethod = "supplier_sku_exact";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// c. description overlap (≥80%)
|
||||||
|
if (!matchedPo && invDesc) {
|
||||||
|
let best: { po: ResolvedPoLine; score: number } | null = null;
|
||||||
|
for (const p of poLines) {
|
||||||
|
if (usedPoIds.has(p.id)) continue;
|
||||||
|
const score = descriptionOverlap(invDesc, p.description);
|
||||||
|
if (!best || score > best.score) best = { po: p, score };
|
||||||
|
}
|
||||||
|
if (best && best.score >= DESCRIPTION_OVERLAP_THRESHOLD) {
|
||||||
|
matchedPo = best.po;
|
||||||
|
matchedMethod = "description_overlap";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (matchedPo) {
|
||||||
|
usedPoIds.add(matchedPo.id);
|
||||||
|
matchedId = matchedPo.id;
|
||||||
|
const qtyDelta = round2(matchedPo.quantity - Number(inv.quantity ?? 0));
|
||||||
|
const costDelta = round2(matchedPo.cost_price - Number(inv.unit_price ?? 0));
|
||||||
|
if (Math.abs(qtyDelta) > MATCH_TOLERANCE) {
|
||||||
|
notes.push(
|
||||||
|
`qty mismatch: PO=${matchedPo.quantity}, invoice=${inv.quantity}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Math.abs(costDelta) > MATCH_TOLERANCE) {
|
||||||
|
notes.push(
|
||||||
|
`cost mismatch: PO=R${matchedPo.cost_price.toFixed(2)}, invoice=R${Number(inv.unit_price ?? 0).toFixed(2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
comparisons.push({
|
||||||
|
invoice_line_no: i + 1,
|
||||||
|
invoice: inv,
|
||||||
|
po_line_id: matchedId,
|
||||||
|
stock_item_id: matchedPo.stock_item_id,
|
||||||
|
match_method: matchedMethod,
|
||||||
|
qty_delta: qtyDelta,
|
||||||
|
cost_delta: costDelta,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
unmatchedInvoiceLines.push({
|
||||||
|
line_no: i + 1,
|
||||||
|
description: invDesc,
|
||||||
|
quantity: Number(inv.quantity ?? 0),
|
||||||
|
});
|
||||||
|
comparisons.push({
|
||||||
|
invoice_line_no: i + 1,
|
||||||
|
invoice: inv,
|
||||||
|
po_line_id: null,
|
||||||
|
stock_item_id: null,
|
||||||
|
match_method: "none",
|
||||||
|
qty_delta: null,
|
||||||
|
cost_delta: null,
|
||||||
|
notes: ["no matching PO line found"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect PO lines that nothing matched.
|
||||||
|
for (const p of poLines) {
|
||||||
|
if (!usedPoIds.has(p.id)) {
|
||||||
|
unmatchedPoLines.push({
|
||||||
|
id: p.id,
|
||||||
|
description: p.description,
|
||||||
|
quantity: p.quantity,
|
||||||
|
cost_price: p.cost_price,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Verdict ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `wrong_status` is sticky — it preempts any other verdict so the
|
||||||
|
// buyer always sees the actual blocker first. When the PO is in
|
||||||
|
// `sent` (the only receiveable state), the other verdicts apply.
|
||||||
|
let verdict: MatchVerdict;
|
||||||
|
if (poStatus !== "sent") {
|
||||||
|
verdict = "wrong_status";
|
||||||
|
} else if (unmatchedInvoiceLines.length > 0) {
|
||||||
|
verdict = "unmatched_invoice_lines";
|
||||||
|
} else if (unmatchedPoLines.length > 0) {
|
||||||
|
verdict = "unmatched_po_lines";
|
||||||
|
} else if (comparisons.some((c) => c.qty_delta !== null && Math.abs(c.qty_delta) > MATCH_TOLERANCE)) {
|
||||||
|
verdict = "qty_mismatch";
|
||||||
|
} else if (comparisons.some((c) => c.cost_delta !== null && Math.abs(c.cost_delta) > MATCH_TOLERANCE)) {
|
||||||
|
verdict = "cost_mismatch";
|
||||||
|
} else if (supplierMismatch) {
|
||||||
|
verdict = "supplier_mismatch";
|
||||||
|
} else {
|
||||||
|
verdict = "ready_to_receive";
|
||||||
|
}
|
||||||
|
|
||||||
|
const report: MatchReport = {
|
||||||
|
verdict,
|
||||||
|
po_id: poId,
|
||||||
|
po_number: poNumber,
|
||||||
|
po_status: poStatus,
|
||||||
|
po_supplier_id: poSupplierId,
|
||||||
|
po_supplier_name: poSupplierName,
|
||||||
|
invoice_supplier_name: input.invoice.supplier_name ?? null,
|
||||||
|
ordered_value: orderedValue,
|
||||||
|
received_value: receivedValue,
|
||||||
|
unmatched_po_lines: unmatchedPoLines,
|
||||||
|
unmatched_invoice_lines: unmatchedInvoiceLines,
|
||||||
|
line_comparisons: comparisons,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dry-run short-circuit: return now.
|
||||||
|
if (dryRun) {
|
||||||
|
const attachmentFilename = input.attachment
|
||||||
|
? buildSupplierInvoiceFilename(
|
||||||
|
poSupplierName || input.invoice.supplier_name || "unknown",
|
||||||
|
input.invoice.document_no,
|
||||||
|
input.invoice.date,
|
||||||
|
deriveExt(input.attachment.filename, input.attachment.content_type),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: "dry_run",
|
||||||
|
report,
|
||||||
|
would_receive: verdict === "ready_to_receive",
|
||||||
|
attachment_filename: attachmentFilename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Commit path ──────────────────────────────────────────────────
|
||||||
|
if (verdict !== "ready_to_receive") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mode: "committed",
|
||||||
|
reason: verdict,
|
||||||
|
message: `Refused to commit — verdict is "${verdict}". Resolve the mismatches and call again.`,
|
||||||
|
report,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the invoice image first (idempotent enough — PB appends a
|
||||||
|
// token, so a second call adds a duplicate). We don't try to dedupe.
|
||||||
|
let attachmentEcho: MatchAndReceiveCommitted["attachment"] = null;
|
||||||
|
if (input.attachment) {
|
||||||
|
const bytes = Buffer.from(String(input.attachment.base64), "base64");
|
||||||
|
const filename = buildSupplierInvoiceFilename(
|
||||||
|
poSupplierName || input.invoice.supplier_name || "unknown",
|
||||||
|
input.invoice.document_no,
|
||||||
|
input.invoice.date,
|
||||||
|
deriveExt(input.attachment.filename, input.attachment.content_type),
|
||||||
|
);
|
||||||
|
const file = new File([new Uint8Array(bytes)], filename, {
|
||||||
|
type: input.attachment.content_type || "application/octet-stream",
|
||||||
|
});
|
||||||
|
const att = await addStockPurchaseAttachment({
|
||||||
|
purchaseId: poId,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
attachmentEcho = {
|
||||||
|
filename,
|
||||||
|
added: att.added,
|
||||||
|
attachments: att.attachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recv = await receiveStockPurchase(pb, poId);
|
||||||
|
if (!recv.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mode: "committed",
|
||||||
|
reason: verdict,
|
||||||
|
message: `Attachment uploaded but receive failed: ${recv.reason} — ${recv.message ?? ""}`,
|
||||||
|
report,
|
||||||
|
partial: { attachment_added: attachmentEcho?.added ?? null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: "committed",
|
||||||
|
report,
|
||||||
|
receive: {
|
||||||
|
purchase_id: poId,
|
||||||
|
purchase_number: poNumber,
|
||||||
|
status: String((recv.purchase as Record<string, unknown>)?.status ?? "received"),
|
||||||
|
received_value: Number((recv.purchase as Record<string, unknown>)?.received_value ?? orderedValue),
|
||||||
|
ordered_value: orderedValue,
|
||||||
|
lines_booked: recv.lines?.length ?? 0,
|
||||||
|
},
|
||||||
|
attachment: attachmentEcho,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function emptyReport(input: MatchAndReceiveInput): MatchReport {
|
||||||
|
return {
|
||||||
|
verdict: "po_not_found",
|
||||||
|
po_id: null,
|
||||||
|
po_number: null,
|
||||||
|
po_status: null,
|
||||||
|
po_supplier_id: null,
|
||||||
|
po_supplier_name: null,
|
||||||
|
invoice_supplier_name: input.invoice.supplier_name ?? null,
|
||||||
|
ordered_value: 0,
|
||||||
|
received_value: 0,
|
||||||
|
unmatched_po_lines: [],
|
||||||
|
unmatched_invoice_lines: [],
|
||||||
|
line_comparisons: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveExt(filename: string, contentType: string): string {
|
||||||
|
const fromName = filename.split(".").pop() ?? "";
|
||||||
|
if (fromName && fromName.length <= 5) return fromName.toLowerCase();
|
||||||
|
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
||||||
|
if (contentType.includes("png")) return "png";
|
||||||
|
if (contentType.includes("pdf")) return "pdf";
|
||||||
|
if (contentType.includes("webp")) return "webp";
|
||||||
|
if (contentType.includes("heic")) return "heic";
|
||||||
|
return "bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(
|
||||||
|
dryRun: boolean,
|
||||||
|
report: MatchReport,
|
||||||
|
message?: string | null,
|
||||||
|
): MatchAndReceiveResult {
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: "dry_run",
|
||||||
|
report,
|
||||||
|
would_receive: report.verdict === "ready_to_receive",
|
||||||
|
attachment_filename: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
mode: "committed",
|
||||||
|
reason: report.verdict,
|
||||||
|
message: message ?? `Refused to commit — verdict is "${report.verdict}".`,
|
||||||
|
report,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue