diff --git a/.agents/skills/wfdash-giellie/SKILL.md b/.agents/skills/wfdash-giellie/SKILL.md index a3fe78b..eca7304 100644 --- a/.agents/skills/wfdash-giellie/SKILL.md +++ b/.agents/skills/wfdash-giellie/SKILL.md @@ -1,6 +1,6 @@ --- 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 @@ -197,7 +197,7 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else ## 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) @@ -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.send`, `stock_purchases.receive` | Send to supplier / receive goods. | | `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___.` 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. | ### 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": "" + } + // 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___. +``` + +The `` 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) These are live issues on production. Verify they're still current before reporting them. diff --git a/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/5297a033-be04-4c3a-8f97-2b42f719af5b-image.png b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/5297a033-be04-4c3a-8f97-2b42f719af5b-image.png new file mode 100644 index 0000000..5671d73 Binary files /dev/null and b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/5297a033-be04-4c3a-8f97-2b42f719af5b-image.png differ diff --git a/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/7405f614-b411-4326-b8a5-3efd72f4cc9b-image.png b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/7405f614-b411-4326-b8a5-3efd72f4cc9b-image.png new file mode 100644 index 0000000..0341d7a Binary files /dev/null and b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/7405f614-b411-4326-b8a5-3efd72f4cc9b-image.png differ diff --git a/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/b8c55537-a4ff-4d16-ba38-f82ad0fb4245-image.png b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/b8c55537-a4ff-4d16-ba38-f82ad0fb4245-image.png new file mode 100644 index 0000000..ed9f01c Binary files /dev/null and b/.opencode/openwork/inbox/chat-attachments/ses_019256f62ffeDwHi23C94lev5T/b8c55537-a4ff-4d16-ba38-f82ad0fb4245-image.png differ diff --git a/src/app/api/mcp/FEATURE-REQUESTS.md b/src/app/api/mcp/FEATURE-REQUESTS.md index 20e58a2..12c43bc 100644 --- a/src/app/api/mcp/FEATURE-REQUESTS.md +++ b/src/app/api/mcp/FEATURE-REQUESTS.md @@ -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-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___.` 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). diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 9babcca..23baa09 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -67,6 +67,12 @@ vi.mock("@/lib/server/stock-purchase-items-service", () => ({ 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. const updateStockItem = vi.fn(); vi.mock("@/lib/server/stock-items-service", () => ({ @@ -111,6 +117,7 @@ describe("POST /api/mcp", () => { assignDriver.mockReset(); markLineCollected.mockReset(); updateStockItem.mockReset(); + matchAndReceiveFromInvoiceMock.mockReset(); }); it("401s without bearer token", async () => { @@ -142,7 +149,7 @@ describe("POST /api/mcp", () => { const body = await res.json(); expect(body.jsonrpc).toBe("2.0"); 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([ // Orders / quotes (existing) "orders.list", @@ -156,6 +163,10 @@ describe("POST /api/mcp", () => { "order_items.list", "order_items.get", "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.find_by_message_id", "quotes.list", @@ -190,6 +201,8 @@ describe("POST /api/mcp", () => { "quote_items.get", "quote_items.create", "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.list", "stock_items.get", @@ -206,6 +219,8 @@ describe("POST /api/mcp", () => { "stock_purchases.send_email", "stock_purchases.retry_email", "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) "stock_purchase_items.update", "stock_purchase_items.bulk_update", @@ -224,6 +239,9 @@ describe("POST /api/mcp", () => { "stock.surplus_book", // AI insights rerun (added 2026-07-30) "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) "companies.list", "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 () => { updateStockPurchaseItem.mockResolvedValueOnce({ item: { id: "spi-1", sku: "NEW-SKU" }, diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 81d8f84..fbca9ba 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -29,6 +29,7 @@ import { type CreateStockPurchaseInput, type StockPurchaseStatus, } 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 { populateQuoteItemInsights, @@ -1268,25 +1269,101 @@ const tools = [ properties: { id: { type: "string" } }, }, }, - { +{ name: "stock_purchases.add_attachment", 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.", inputSchema: { - type: "object", - required: ["purchase_id", "filename", "base64"], + "type": "object", + "required": ["purchase_id", "filename", "base64"], properties: { purchase_id: { - type: "string", - description: "15-char PocketBase stock_purchase id", + "type": "string", + "description": "15-char PocketBase stock_purchase id", }, - filename: { type: "string" }, - content_type: { type: "string" }, - size: { type: "number" }, + 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___.` — 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: - "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; + const attachmentRaw = args.attachment; + const attachment = + attachmentRaw && typeof attachmentRaw === "object" + ? { + filename: String( + (attachmentRaw as Record).filename ?? "", + ), + content_type: String( + (attachmentRaw as Record).content_type ?? "", + ), + base64: String( + (attachmentRaw as Record).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>).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") { const purchase_number = await generateStockPurchaseNumber(pb); return jsonRpcOk(body.id, { purchase_number }); diff --git a/src/lib/server/stock-purchase-invoice-matching-service.test.ts b/src/lib/server/stock-purchase-invoice-matching-service.test.ts new file mode 100644 index 0000000..b99f139 --- /dev/null +++ b/src/lib/server/stock-purchase-invoice-matching-service.test.ts @@ -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___` 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); + }); +}); \ No newline at end of file diff --git a/src/lib/server/stock-purchase-invoice-matching-service.ts b/src/lib/server/stock-purchase-invoice-matching-service.ts new file mode 100644 index 0000000..4886172 --- /dev/null +++ b/src/lib/server/stock-purchase-invoice-matching-service.ts @@ -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___.` + * (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 { + 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 | 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) ?? 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>; + + 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(); + 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)?.status ?? "received"), + received_value: Number((recv.purchase as Record)?.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, + }; +} \ No newline at end of file