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:
SJCoetz 2026-08-09 16:59:44 +02:00
parent 63048e51c9
commit 60d320411d
9 changed files with 1415 additions and 12 deletions

View file

@ -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.

View file

@ -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).

View file

@ -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" },

View file

@ -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,25 +1269,101 @@ 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" }, filename: { "type": "string" },
content_type: { type: "string" }, content_type: { "type": "string" },
size: { type: "number" }, size: { "type": "number" },
base64: { 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", type: "string",
description: description:
"Raw file bytes encoded as base64 (no `data:` URL prefix).", "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" },
content_type: { type: "string" },
base64: {
type: "string",
description:
"Raw file bytes encoded as base64 (no `data:` URL prefix).",
},
},
},
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`.",
}, },
}, },
}, },
@ -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 });

View 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);
});
});

View 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,
};
}