feat(mcp): add stock purchase invoice reconciliation tool
Add the full buyer-side supplier invoice reconciliation workflow: - Include the new `stock_purchases.match_and_receive_from_invoice` MCP tool for deterministic PO-invoice matching - Create core matching service with strict, rule-based line matching (sku → supplier_sku → description overlap) - Support dry-run default mode to validate matches before committing - Add automatic attachment handling with standard `supplier_invoice_*` naming convention - Add full test suite for the matching logic - Update documentation and agent skill profile to document the new tool and workflow Pre-existing test failures (3 total) are unrelated to this change.
This commit is contained in:
parent
63048e51c9
commit
60d320411d
9 changed files with 1415 additions and 12 deletions
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: wfdash-giellie
|
||||
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_<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. |
|
||||
|
||||
### 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)
|
||||
|
||||
These are live issues on production. Verify they're still current before reporting them.
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 714 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 732 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 882 KiB |
|
|
@ -129,3 +129,4 @@ These came up but need a human answer before code changes:
|
|||
|
||||
- 2026-08-06 — `stock_purchases.add_attachment` shipped (commits `f89bbb3`, `dd57d29`). Adds a sibling to `purchase_orders.additional_attachments` plus the matching REST upload/delete routes and a UI card on `/stock-orders/[id]`. PB schema: new `stock_purchases.additional_attachments` file field (multi, maxSelect 10, any MIME).
|
||||
- 2026-08-07 — Split-invoice stock-PO workflow shipped. 5 new MCP tools (`stock_purchase_items.update`, `stock_purchase_items.bulk_update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.mark_collected`, `stock_items.update`) and the underlying services (`stock-purchase-items-service.ts`, extended `stock-items-service.ts`). Closes the per-line write gap surfaced by the operator's split-delivery scenario (PO with multiple supplier invoices arriving on different days). `mark_collected` auto-flips the parent PO header to `received` when every line is fully collected. PB schema: new `stock_purchase_items.sku` / `unit` / `supplier_sku` text fields (migration `scripts/migrations/2026-08-07-stock-purchase-items-catalogue-fields.ts`, user applies via PB admin).
|
||||
- 2026-08-09 — Buyer-side supplier-invoice reconciliation shipped. New MCP tool `stock_purchases.match_and_receive_from_invoice` (single commit, no schema change). The agent parses a scanned supplier invoice with its native vision, passes the structured fields (`po_reference`, `invoice.{document_no,date,supplier_name?,lines[]}`, optional `attachment` bytes) plus a `confirm` flag. Deterministic line-by-line matching: `stock_item.sku` exact → `stock_purchase_item.supplier_sku` exact → description token overlap (≥80%). Verdict enum (`ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`) drives the HTTP status (200/404/409/422). Dry-run by default; `confirm=true` only commits when `verdict === "ready_to_receive"` — on success it attaches the invoice image using the `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>` naming convention (PB appends its random token at write time, matching the operator's existing `supplier_invoice_afriq_ina64721_2026_08_06_…` format on PO26073109STK), then calls `stock_purchases.receive` in the same round-trip. Closes the "AI checks my invoices for the buyer" workflow surfaced 2026-08-09. Implementation: new `src/lib/server/stock-purchase-invoice-matching-service.ts` (+ matching `.test.ts`), wired into `src/app/api/mcp/route.ts` (one new tool entry + one dispatch block + 4 dispatch tests). 1,654/1,657 vitest tests pass; the 3 failing tests are pre-existing (PocketBase SDK mock mismatch in `pocketbase-admin.test.ts`, 5s timeout in `quotes/items/[id]/attachments/upload/route.test.ts` — both reproducible on `main` without this change).
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ vi.mock("@/lib/server/stock-purchase-items-service", () => ({
|
|||
markLineCollected: (...args: unknown[]) => markLineCollected(...args),
|
||||
}));
|
||||
|
||||
// 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" },
|
||||
|
|
|
|||
|
|
@ -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_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>` — PocketBase appends a random token, so on-disk the name looks like `supplier_invoice_hpi_inserts_IN389384_2026-08-05_z7ho1rrzk9.jpg`. Verdict enum: `ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`. With `confirm=false` (default), the tool only reports — it never writes. With `confirm=true`, the receive is **blocked** unless `verdict === \"ready_to_receive\"`; on success it attaches the invoice (if `attachment` was supplied) and then calls `stock_purchases.receive` in one round-trip.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["po_reference", "invoice"],
|
||||
properties: {
|
||||
po_reference: {
|
||||
type: "string",
|
||||
description:
|
||||
"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") {
|
||||
const purchase_number = await generateStockPurchaseNumber(pb);
|
||||
return jsonRpcOk(body.id, { purchase_number });
|
||||
|
|
|
|||
148
src/lib/server/stock-purchase-invoice-matching-service.test.ts
Normal file
148
src/lib/server/stock-purchase-invoice-matching-service.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Pure logic tests for the supplier-invoice matching helpers.
|
||||
*
|
||||
* The actual `matchAndReceiveFromInvoice` flow touches PocketBase;
|
||||
* for that we rely on the existing route-level mocks in
|
||||
* `src/app/api/mcp/route.test.ts`. Here we focus on the deterministic
|
||||
* functions the operator is most likely to get wrong:
|
||||
*
|
||||
* - filename composition (the operator's mental model of the
|
||||
* `supplier_invoice_<slug>_<no>_<date>` convention)
|
||||
* - supplier-name fuzzy matching (used as the soft cross-check)
|
||||
* - description overlap scoring (used as a last-resort fallback)
|
||||
*/
|
||||
|
||||
import {
|
||||
buildSupplierInvoiceFilename,
|
||||
descriptionOverlap,
|
||||
slugifyInvoiceNumber,
|
||||
slugifySupplierName,
|
||||
supplierNamesMatch,
|
||||
} from "./stock-purchase-invoice-matching-service";
|
||||
|
||||
describe("slugifySupplierName", () => {
|
||||
it("lowercases and replaces non-alnum with underscores", () => {
|
||||
expect(slugifySupplierName("HPI INSERTS")).toBe("hpi_inserts");
|
||||
});
|
||||
|
||||
it("strips common entity suffixes", () => {
|
||||
expect(slugifySupplierName("AFRIQ LUBE AND SERVICES CC")).toBe("afriq_lube_and_services");
|
||||
});
|
||||
|
||||
it("caps at 32 chars to keep filenames sane", () => {
|
||||
const long = "A".repeat(60);
|
||||
expect(slugifySupplierName(long).length).toBeLessThanOrEqual(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slugifyInvoiceNumber", () => {
|
||||
it("uppercases and keeps alnum", () => {
|
||||
expect(slugifyInvoiceNumber("in389384")).toBe("IN389384");
|
||||
});
|
||||
|
||||
it("strips punctuation but keeps the structure", () => {
|
||||
expect(slugifyInvoiceNumber("INV-2026/08/001")).toBe("INV_2026_08_001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSupplierInvoiceFilename", () => {
|
||||
it("composes the canonical supplier_invoice_… name", () => {
|
||||
const name = buildSupplierInvoiceFilename(
|
||||
"HPI INSERTS",
|
||||
"IN389384",
|
||||
"2026-08-05",
|
||||
"jpg",
|
||||
);
|
||||
expect(name).toBe(
|
||||
"supplier_invoice_hpi_inserts_IN389384_2026-08-05.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to jpg when extension is empty", () => {
|
||||
expect(
|
||||
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", ""),
|
||||
).toMatch(/\.jpg$/);
|
||||
});
|
||||
|
||||
it("uses the supplied extension verbatim (lowercased)", () => {
|
||||
expect(
|
||||
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", "PNG"),
|
||||
).toMatch(/\.png$/);
|
||||
});
|
||||
|
||||
it("strips a leading dot from the extension", () => {
|
||||
expect(
|
||||
buildSupplierInvoiceFilename("HPI", "X1", "2026-01-01", ".pdf"),
|
||||
).toMatch(/\.pdf$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supplierNamesMatch", () => {
|
||||
it("returns true for identical names", () => {
|
||||
expect(supplierNamesMatch("HPI INSERTS", "HPI INSERTS")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when PTY/LTD suffix differs", () => {
|
||||
expect(supplierNamesMatch("HPI INSERTS (PTY) LTD", "HPI INSERTS")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when CC suffix differs", () => {
|
||||
expect(
|
||||
supplierNamesMatch("AFRIQ LUBE AND SERVICES CC", "AFRIQ LUBE AND SERVICES"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for clearly different names", () => {
|
||||
expect(supplierNamesMatch("HPI INSERTS", "TEL-SCREW PRODUCTS")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when one input is empty", () => {
|
||||
expect(supplierNamesMatch("", "HPI INSERTS")).toBe(false);
|
||||
expect(supplierNamesMatch("HPI INSERTS", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descriptionOverlap", () => {
|
||||
it("returns 1 for identical descriptions", () => {
|
||||
expect(descriptionOverlap("HPI RIVNUT TOOL 610 M3-M12", "HPI RIVNUT TOOL 610 M3-M12")).toBe(1);
|
||||
});
|
||||
|
||||
it("is high for near-identical descriptions (typo on one)", () => {
|
||||
const a = "HPI RIVNUT TOOL 610 M3-M12";
|
||||
const b = "HPI RIVNUT TOOL 510 M3 - M12"; // invoice typo: 510 vs 610
|
||||
// Should still be ≥0.8 — the meaningful tokens all overlap.
|
||||
expect(descriptionOverlap(a, b)).toBeGreaterThanOrEqual(0.8);
|
||||
});
|
||||
|
||||
it("drops noise tokens (single digits)", () => {
|
||||
const a = "M16 X 50 BOLT BOX OF 100";
|
||||
const b = "BOLT BOX";
|
||||
expect(descriptionOverlap(a, b)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 when nothing matches", () => {
|
||||
expect(descriptionOverlap("HPI RIVNUT TOOL", "GREASE 15KG")).toBe(0);
|
||||
});
|
||||
|
||||
it("handles empty inputs without crashing", () => {
|
||||
expect(descriptionOverlap("", "GREASE 15KG")).toBe(0);
|
||||
expect(descriptionOverlap("HPI RIVNUT TOOL", "")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-exported constants sanity check — guards against accidental removal.
|
||||
import {
|
||||
MATCH_TOLERANCE,
|
||||
DESCRIPTION_OVERLAP_THRESHOLD,
|
||||
} from "./stock-purchase-invoice-matching-service";
|
||||
|
||||
describe("exported constants", () => {
|
||||
it("MATCH_TOLERANCE is 1 cent", () => {
|
||||
expect(MATCH_TOLERANCE).toBe(0.01);
|
||||
});
|
||||
it("DESCRIPTION_OVERLAP_THRESHOLD is 0.8", () => {
|
||||
expect(DESCRIPTION_OVERLAP_THRESHOLD).toBe(0.8);
|
||||
});
|
||||
});
|
||||
736
src/lib/server/stock-purchase-invoice-matching-service.ts
Normal file
736
src/lib/server/stock-purchase-invoice-matching-service.ts
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
/**
|
||||
* Stock purchase invoice matching — agent-side reconciliation.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* `stock_purchases.receive` already does the actual bookkeeping (status
|
||||
* flip, ledger rows, stock_items.current_qty bump). What's *missing*
|
||||
* for a buyer is the workflow that takes a **scanned supplier invoice
|
||||
* image** and decides whether the PO it refers to can be received
|
||||
* safely. That's the bridge between "the supplier posted an invoice in
|
||||
* the WhatsApp group" and "press receive".
|
||||
*
|
||||
* The two existing flows this augments:
|
||||
*
|
||||
* - `stock_purchases.receive` (`stock-purchases-service.ts`) — the
|
||||
* mechanic. Idempotent on `status="received"`.
|
||||
* - `bookStockFromInvoice` (`stock-booking-service.ts`) — the
|
||||
* accountant's later commit. This module deliberately does **not**
|
||||
* touch it; the buyer-side receive happens first, then the
|
||||
* accountant books it for payment-cycle reasoning.
|
||||
*
|
||||
* Where OCR happens
|
||||
* ─────────────────
|
||||
* The MCP server stays stateless and PB-only. The agent (an LLM) reads
|
||||
* the invoice image with its native vision capability and passes the
|
||||
* parsed fields in. We then do the matching, validation, and decision
|
||||
* deterministically against PocketBase.
|
||||
*
|
||||
* Why deterministic matching matters here
|
||||
* ───────────────────────────────────────
|
||||
* A buyer who lets an LLM decide "looks close enough, ship it" will
|
||||
* book the wrong stock. So the matching algorithm is rule-based:
|
||||
*
|
||||
* 1. Resolve the PO by `purchase_number` (the "Your Ref" field on the
|
||||
* invoice).
|
||||
* 2. Resolve the supplier and compare it to the invoice's
|
||||
* `supplier_name` (sanity check, not a hard block).
|
||||
* 3. For each invoice line, try to find a matching PO line by:
|
||||
* a. `stock_item.sku` exact match to `invoice.code`
|
||||
* b. `stock_purchase_item.supplier_sku` exact match to
|
||||
* `invoice.code`
|
||||
* c. Description token overlap (≥80% of invoice tokens appear
|
||||
* in the PO line description)
|
||||
* 4. Verify qty + cost match (within 1c floating-point tolerance).
|
||||
*
|
||||
* If any of those fail, the verdict is one of `po_not_found`,
|
||||
* `wrong_status`, `supplier_mismatch`, `unmatched_po_lines`,
|
||||
* `unmatched_invoice_lines`, `qty_mismatch`, `cost_mismatch` — and
|
||||
* the receive is **blocked** regardless of `confirm: true`. The agent
|
||||
* surfaces the verdict + per-line reasons to the buyer, who decides
|
||||
* manually whether to fix the PO or override.
|
||||
*
|
||||
* The filename convention
|
||||
* ───────────────────────
|
||||
* Attachments uploaded through this tool are named
|
||||
* `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>`
|
||||
* (PB appends its own random token at write time, so the on-disk name
|
||||
* looks like `..._z7ho1rrzk9.jpg`). The `supplier_invoice_` prefix is
|
||||
* what the operator's PB filter uses to surface invoice attachments
|
||||
* distinctly from generic PO attachments — see the AFRIQ example on
|
||||
* PO26073109STK.
|
||||
*/
|
||||
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import {
|
||||
addStockPurchaseAttachment,
|
||||
receiveStockPurchase,
|
||||
} from "@/lib/server/stock-purchases-service";
|
||||
|
||||
// ─── Constants ─────────────────────────────────────────────────────────
|
||||
|
||||
const STOCK_PURCHASES = "stock_purchases";
|
||||
const STOCK_PURCHASE_ITEMS = "stock_purchase_items";
|
||||
const STOCK_ITEMS = "stock_items";
|
||||
|
||||
// Reused here so the supplier-invoice prefix lives in exactly one place.
|
||||
export const SUPPLIER_INVOICE_FILENAME_PREFIX = "supplier_invoice_";
|
||||
|
||||
// Re-exported so route.ts can echo them in error messages without
|
||||
// re-hardcoding.
|
||||
export const MATCH_TOLERANCE = 0.01;
|
||||
export const DESCRIPTION_OVERLAP_THRESHOLD = 0.8;
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** A single line on the supplier's invoice, parsed by the agent's vision. */
|
||||
export type InvoiceLine = {
|
||||
/** Supplier's SKU / catalogue code (e.g. "56020510"). */
|
||||
code?: string;
|
||||
/** Free-text description as printed on the invoice. */
|
||||
description: string;
|
||||
/** Number of units on this line. */
|
||||
quantity: number;
|
||||
/** Nett unit price as printed, in ZAR. */
|
||||
unit_price: number;
|
||||
/** Optional line total — only used for cross-check if present. */
|
||||
line_total?: number;
|
||||
};
|
||||
|
||||
/** Parsed invoice header + lines, supplied by the calling agent. */
|
||||
export type ParsedInvoice = {
|
||||
/** Supplier's invoice number (the "Document No" on the printed form). */
|
||||
document_no: string;
|
||||
/** Invoice date in `YYYY-MM-DD` (already converted from `DD/MM/YY`). */
|
||||
date: string;
|
||||
/** Optional supplier name as printed on the invoice — for sanity. */
|
||||
supplier_name?: string;
|
||||
/** Line items in document order. */
|
||||
lines: InvoiceLine[];
|
||||
};
|
||||
|
||||
/** Optional image bytes to attach with the supplier-invoice naming. */
|
||||
export type InvoiceAttachment = {
|
||||
filename: string;
|
||||
content_type: string;
|
||||
base64: string;
|
||||
};
|
||||
|
||||
export type MatchAndReceiveInput = {
|
||||
/**
|
||||
* The "Your Ref" on the invoice — i.e. the `stock_purchases.purchase_number`
|
||||
* (e.g. "PO26080501STK"). Case-insensitive; whitespace-trimmed.
|
||||
*/
|
||||
po_reference: string;
|
||||
/** Parsed invoice content from the agent's vision pass. */
|
||||
invoice: ParsedInvoice;
|
||||
/**
|
||||
* If provided, the raw invoice bytes get uploaded as an attachment
|
||||
* with the `supplier_invoice_…` filename convention. Skip this when
|
||||
* the operator only wants the matching report.
|
||||
*/
|
||||
attachment?: InvoiceAttachment;
|
||||
/**
|
||||
* `false` (default) — dry run. Returns the matching report and
|
||||
* `would_receive: true/false`, but does NOT touch PB.
|
||||
* `true` — commit. Only proceeds when verdict is `ready_to_receive`.
|
||||
*/
|
||||
confirm?: boolean;
|
||||
};
|
||||
|
||||
/** Verdict strings, stable for branching on the agent side. */
|
||||
export type MatchVerdict =
|
||||
| "ready_to_receive"
|
||||
| "po_not_found"
|
||||
| "wrong_status"
|
||||
| "supplier_mismatch"
|
||||
| "unmatched_po_lines"
|
||||
| "unmatched_invoice_lines"
|
||||
| "qty_mismatch"
|
||||
| "cost_mismatch";
|
||||
|
||||
export type LineComparison = {
|
||||
/** Invoice line index (1-based, for human display). */
|
||||
invoice_line_no: number;
|
||||
/** What the invoice said. */
|
||||
invoice: InvoiceLine;
|
||||
/** Matching PO line id, or null if no match. */
|
||||
po_line_id: string | null;
|
||||
/** Matching stock_item id (via the PO line), or null. */
|
||||
stock_item_id: string | null;
|
||||
/** What matching strategy produced this pairing. */
|
||||
match_method: "sku_exact" | "supplier_sku_exact" | "description_overlap" | "none";
|
||||
/** Difference between PO line `quantity` and invoice `quantity` (PO - invoice). */
|
||||
qty_delta: number | null;
|
||||
/** Difference between PO line `cost_price` and invoice `unit_price`. */
|
||||
cost_delta: number | null;
|
||||
/** Human-readable notes for the buyer. */
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type MatchReport = {
|
||||
verdict: MatchVerdict;
|
||||
po_id: string | null;
|
||||
po_number: string | null;
|
||||
po_status: string | null;
|
||||
po_supplier_id: string | null;
|
||||
po_supplier_name: string | null;
|
||||
invoice_supplier_name: string | null;
|
||||
ordered_value: number;
|
||||
received_value: number;
|
||||
/** PO lines that could NOT be paired with any invoice line. */
|
||||
unmatched_po_lines: Array<{ id: string; description: string; quantity: number; cost_price: number }>;
|
||||
/** Invoice lines that could NOT be paired with any PO line. */
|
||||
unmatched_invoice_lines: Array<{ line_no: number; description: string; quantity: number }>;
|
||||
/** Per-line reconciliation (1 entry per invoice line). */
|
||||
line_comparisons: LineComparison[];
|
||||
};
|
||||
|
||||
export type MatchAndReceiveDryRun = {
|
||||
ok: true;
|
||||
mode: "dry_run";
|
||||
report: MatchReport;
|
||||
/** `true` if a `confirm: true` call would have proceeded. */
|
||||
would_receive: boolean;
|
||||
/** Filename the attachment *would* be uploaded as, if any was supplied. */
|
||||
attachment_filename: string | null;
|
||||
};
|
||||
|
||||
export type MatchAndReceiveCommitted = {
|
||||
ok: true;
|
||||
mode: "committed";
|
||||
report: MatchReport;
|
||||
/** Echo of the underlying `receiveStockPurchase` payload. */
|
||||
receive: {
|
||||
purchase_id: string;
|
||||
purchase_number: string;
|
||||
status: string;
|
||||
received_value: number;
|
||||
ordered_value: number;
|
||||
lines_booked: number;
|
||||
};
|
||||
/** Echo of the underlying attachment call (or null if no attachment was supplied). */
|
||||
attachment: {
|
||||
filename: string;
|
||||
added: string[];
|
||||
attachments: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MatchAndReceiveBlocked = {
|
||||
ok: false;
|
||||
mode: "committed";
|
||||
reason: MatchVerdict;
|
||||
message: string;
|
||||
report: MatchReport;
|
||||
/** Echo of any partial work — e.g. the attachment was uploaded but the receive was blocked. */
|
||||
partial?: {
|
||||
attachment_added: string[] | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatchAndReceiveResult =
|
||||
| MatchAndReceiveDryRun
|
||||
| MatchAndReceiveCommitted
|
||||
| MatchAndReceiveBlocked;
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Slugify a supplier name for the filename:
|
||||
* - lowercase
|
||||
* - strip common entity suffixes (PTY, LTD, CC, INC, LLC, CO)
|
||||
* - non-alnum → `_`, collapse runs of `_`, trim
|
||||
* - capped at 32 chars to keep filenames sane
|
||||
*
|
||||
* Examples:
|
||||
* "HPI INSERTS" → "hpi_inserts"
|
||||
* "AFRIQ LUBE AND SERVICES CC" → "afriq_lube_and_services"
|
||||
*/
|
||||
export function slugifySupplierName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/\b(pty|ltd|cc|inc|llc|co)\b/g, " ")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
/** Slugify an invoice number: uppercase, non-alnum → `_`. */
|
||||
export function slugifyInvoiceNumber(num: string): string {
|
||||
return num
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
/** Compose the canonical supplier-invoice filename. PB will append its
|
||||
* own random token on write, so this is the prefix only. */
|
||||
export function buildSupplierInvoiceFilename(
|
||||
supplierName: string,
|
||||
invoiceNumber: string,
|
||||
isoDate: string,
|
||||
originalExt: string,
|
||||
): string {
|
||||
const ext = originalExt.replace(/^\.+/, "").toLowerCase() || "jpg";
|
||||
const safeDate = isoDate.replace(/[^0-9-]/g, "");
|
||||
return `${SUPPLIER_INVOICE_FILENAME_PREFIX}${slugifySupplierName(supplierName)}_${slugifyInvoiceNumber(invoiceNumber)}_${safeDate}.${ext}`;
|
||||
}
|
||||
|
||||
/** Normalise two supplier names for a fuzzy equality check.
|
||||
* - lowercase, collapse whitespace, strip "PTY LTD", "CC", "INC", etc.
|
||||
* - returns true when the trimmed form contains ≥80% of the shorter
|
||||
* name's tokens (case-insensitive). Cheap, no library, no LLM. */
|
||||
export function supplierNamesMatch(a: string, b: string): boolean {
|
||||
const strip = (s: string): string =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/\b(pty\s*ltd|pty|ltd|cc|inc|llc|co\b|company)\b/g, " ")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const ta = strip(a);
|
||||
const tb = strip(b);
|
||||
if (!ta || !tb) return false;
|
||||
if (ta === tb) return true;
|
||||
const aTokens = new Set(ta.split(" "));
|
||||
const bTokens = new Set(tb.split(" "));
|
||||
const shorter = aTokens.size <= bTokens.size ? aTokens : bTokens;
|
||||
const longer = aTokens.size <= bTokens.size ? bTokens : aTokens;
|
||||
let hits = 0;
|
||||
for (const t of shorter) if (longer.has(t)) hits++;
|
||||
return hits / shorter.size >= DESCRIPTION_OVERLAP_THRESHOLD;
|
||||
}
|
||||
|
||||
/** Token overlap fraction: how much of `b`'s tokens appear in `a`.
|
||||
* Used to fuzzy-match invoice descriptions to PO line descriptions. */
|
||||
export function descriptionOverlap(a: string, b: string): number {
|
||||
const norm = (s: string): string[] =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter((t) => t.length >= 2); // drop noise ("1", "2" etc.)
|
||||
const aTokens = norm(a);
|
||||
const bTokens = norm(b);
|
||||
if (aTokens.length === 0 || bTokens.length === 0) return 0;
|
||||
const setA = new Set(aTokens);
|
||||
const setB = new Set(bTokens);
|
||||
let hits = 0;
|
||||
for (const t of setB) if (setA.has(t)) hits++;
|
||||
return hits / setB.size;
|
||||
}
|
||||
|
||||
/** Round a number to 2dp to absorb float drift. */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
// ─── Main entry point ──────────────────────────────────────────────────
|
||||
|
||||
export async function matchAndReceiveFromInvoice(
|
||||
input: MatchAndReceiveInput,
|
||||
): Promise<MatchAndReceiveResult> {
|
||||
const pb = await getAdminPocketBase();
|
||||
const poRef = String(input.po_reference ?? "").trim();
|
||||
const dryRun = input.confirm !== true;
|
||||
|
||||
if (!poRef) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: "committed",
|
||||
reason: "po_not_found",
|
||||
message: "po_reference is required",
|
||||
report: emptyReport(input),
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. Resolve PO by purchase_number ─────────────────────────────────
|
||||
let purchase: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const page = await pb
|
||||
.collection(STOCK_PURCHASES)
|
||||
.getList(1, 1, {
|
||||
filter: `purchase_number = "${poRef.replace(/"/g, '\\"')}"`,
|
||||
});
|
||||
purchase = (page.items[0] as Record<string, unknown>) ?? null;
|
||||
} catch {
|
||||
purchase = null;
|
||||
}
|
||||
|
||||
if (!purchase) {
|
||||
const report: MatchReport = {
|
||||
...emptyReport(input),
|
||||
verdict: "po_not_found",
|
||||
po_number: poRef,
|
||||
};
|
||||
return wrap(dryRun, report, null);
|
||||
}
|
||||
|
||||
const poId = String(purchase.id);
|
||||
const poNumber = String(purchase.purchase_number ?? "");
|
||||
const poStatus = String(purchase.status ?? "");
|
||||
const poSupplierId = String(purchase.supplier ?? "");
|
||||
const orderedValue = Number(purchase.ordered_value ?? 0);
|
||||
const receivedValue = Number(purchase.received_value ?? 0);
|
||||
|
||||
// ── 2. Load supplier name (for cross-check) ─────────────────────────
|
||||
let poSupplierName = "";
|
||||
if (poSupplierId) {
|
||||
try {
|
||||
const sup = (await pb.collection("suppliers").getOne(poSupplierId)) as {
|
||||
suppliername?: string;
|
||||
};
|
||||
poSupplierName = sup.suppliername ?? "";
|
||||
} catch {
|
||||
// ignore — leave empty
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Load PO lines + each one's stock_item.sku ────────────────────
|
||||
const rawLines = (await pb
|
||||
.collection(STOCK_PURCHASE_ITEMS)
|
||||
.getFullList({
|
||||
filter: `purchase = "${poId}"`,
|
||||
sort: "created",
|
||||
})) as Array<Record<string, unknown>>;
|
||||
|
||||
type ResolvedPoLine = {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
cost_price: number;
|
||||
stock_item_id: string | null;
|
||||
stock_item_sku: string;
|
||||
supplier_sku: string;
|
||||
};
|
||||
const poLines: ResolvedPoLine[] = [];
|
||||
for (const ln of rawLines) {
|
||||
const stockItemId = (ln.stock_item as string | undefined) ?? null;
|
||||
let stockItemSku = "";
|
||||
if (stockItemId) {
|
||||
try {
|
||||
const si = (await pb.collection(STOCK_ITEMS).getOne(stockItemId)) as {
|
||||
sku?: string;
|
||||
};
|
||||
stockItemSku = si.sku ?? "";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
poLines.push({
|
||||
id: String(ln.id),
|
||||
description: String(ln.description ?? ""),
|
||||
quantity: Number(ln.quantity ?? 0),
|
||||
cost_price: Number(ln.cost_price ?? 0),
|
||||
stock_item_id: stockItemId,
|
||||
stock_item_sku: stockItemSku,
|
||||
supplier_sku: String(ln.supplier_sku ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. Pre-flight: PO status is checked later (after matching) ────
|
||||
//
|
||||
// We don't short-circuit on wrong_status here. Running the matching
|
||||
// algorithm anyway means the buyer sees "this invoice WOULD have
|
||||
// matched PO X perfectly, but PO X is already received — likely a
|
||||
// duplicate invoice you can ignore or chase." Without that info the
|
||||
// report is opaque.
|
||||
// The verdict-finalisation step at the bottom sticks `wrong_status`
|
||||
// when the PO isn't in `sent`, which is the actual blocker for
|
||||
// committing.
|
||||
|
||||
// ── 5. Supplier cross-check (soft — surfaces warning but doesn't block) ─
|
||||
let supplierMismatch = false;
|
||||
if (input.invoice.supplier_name) {
|
||||
if (!supplierNamesMatch(poSupplierName, input.invoice.supplier_name)) {
|
||||
supplierMismatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Match invoice lines to PO lines ──────────────────────────────
|
||||
//
|
||||
// We run the matching algorithm even when the PO is in wrong_status,
|
||||
// so the buyer can see "this invoice WOULD have matched PO26073109STK
|
||||
// perfectly, but the PO is already received — likely a duplicate."
|
||||
// Without the line comparisons, the report is opaque on a duplicate.
|
||||
const invoiceLines = input.invoice.lines ?? [];
|
||||
const usedPoIds = new Set<string>();
|
||||
const comparisons: LineComparison[] = [];
|
||||
const unmatchedPoLines: MatchReport["unmatched_po_lines"] = [];
|
||||
const unmatchedInvoiceLines: MatchReport["unmatched_invoice_lines"] = [];
|
||||
|
||||
for (let i = 0; i < invoiceLines.length; i++) {
|
||||
const inv = invoiceLines[i];
|
||||
const invCode = (inv.code ?? "").trim();
|
||||
const invDesc = (inv.description ?? "").trim();
|
||||
|
||||
let matchedId: string | null = null;
|
||||
let matchedMethod: LineComparison["match_method"] = "none";
|
||||
let matchedPo: ResolvedPoLine | null = null;
|
||||
|
||||
// a. stock_item.sku exact match
|
||||
if (invCode) {
|
||||
for (const p of poLines) {
|
||||
if (usedPoIds.has(p.id)) continue;
|
||||
if (p.stock_item_sku && p.stock_item_sku.toLowerCase() === invCode.toLowerCase()) {
|
||||
matchedPo = p;
|
||||
matchedMethod = "sku_exact";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// b. supplier_sku exact match
|
||||
if (!matchedPo && invCode) {
|
||||
for (const p of poLines) {
|
||||
if (usedPoIds.has(p.id)) continue;
|
||||
if (p.supplier_sku && p.supplier_sku.toLowerCase() === invCode.toLowerCase()) {
|
||||
matchedPo = p;
|
||||
matchedMethod = "supplier_sku_exact";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// c. description overlap (≥80%)
|
||||
if (!matchedPo && invDesc) {
|
||||
let best: { po: ResolvedPoLine; score: number } | null = null;
|
||||
for (const p of poLines) {
|
||||
if (usedPoIds.has(p.id)) continue;
|
||||
const score = descriptionOverlap(invDesc, p.description);
|
||||
if (!best || score > best.score) best = { po: p, score };
|
||||
}
|
||||
if (best && best.score >= DESCRIPTION_OVERLAP_THRESHOLD) {
|
||||
matchedPo = best.po;
|
||||
matchedMethod = "description_overlap";
|
||||
}
|
||||
}
|
||||
|
||||
const notes: string[] = [];
|
||||
if (matchedPo) {
|
||||
usedPoIds.add(matchedPo.id);
|
||||
matchedId = matchedPo.id;
|
||||
const qtyDelta = round2(matchedPo.quantity - Number(inv.quantity ?? 0));
|
||||
const costDelta = round2(matchedPo.cost_price - Number(inv.unit_price ?? 0));
|
||||
if (Math.abs(qtyDelta) > MATCH_TOLERANCE) {
|
||||
notes.push(
|
||||
`qty mismatch: PO=${matchedPo.quantity}, invoice=${inv.quantity}`,
|
||||
);
|
||||
}
|
||||
if (Math.abs(costDelta) > MATCH_TOLERANCE) {
|
||||
notes.push(
|
||||
`cost mismatch: PO=R${matchedPo.cost_price.toFixed(2)}, invoice=R${Number(inv.unit_price ?? 0).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
comparisons.push({
|
||||
invoice_line_no: i + 1,
|
||||
invoice: inv,
|
||||
po_line_id: matchedId,
|
||||
stock_item_id: matchedPo.stock_item_id,
|
||||
match_method: matchedMethod,
|
||||
qty_delta: qtyDelta,
|
||||
cost_delta: costDelta,
|
||||
notes,
|
||||
});
|
||||
} else {
|
||||
unmatchedInvoiceLines.push({
|
||||
line_no: i + 1,
|
||||
description: invDesc,
|
||||
quantity: Number(inv.quantity ?? 0),
|
||||
});
|
||||
comparisons.push({
|
||||
invoice_line_no: i + 1,
|
||||
invoice: inv,
|
||||
po_line_id: null,
|
||||
stock_item_id: null,
|
||||
match_method: "none",
|
||||
qty_delta: null,
|
||||
cost_delta: null,
|
||||
notes: ["no matching PO line found"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect PO lines that nothing matched.
|
||||
for (const p of poLines) {
|
||||
if (!usedPoIds.has(p.id)) {
|
||||
unmatchedPoLines.push({
|
||||
id: p.id,
|
||||
description: p.description,
|
||||
quantity: p.quantity,
|
||||
cost_price: p.cost_price,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. Verdict ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `wrong_status` is sticky — it preempts any other verdict so the
|
||||
// buyer always sees the actual blocker first. When the PO is in
|
||||
// `sent` (the only receiveable state), the other verdicts apply.
|
||||
let verdict: MatchVerdict;
|
||||
if (poStatus !== "sent") {
|
||||
verdict = "wrong_status";
|
||||
} else if (unmatchedInvoiceLines.length > 0) {
|
||||
verdict = "unmatched_invoice_lines";
|
||||
} else if (unmatchedPoLines.length > 0) {
|
||||
verdict = "unmatched_po_lines";
|
||||
} else if (comparisons.some((c) => c.qty_delta !== null && Math.abs(c.qty_delta) > MATCH_TOLERANCE)) {
|
||||
verdict = "qty_mismatch";
|
||||
} else if (comparisons.some((c) => c.cost_delta !== null && Math.abs(c.cost_delta) > MATCH_TOLERANCE)) {
|
||||
verdict = "cost_mismatch";
|
||||
} else if (supplierMismatch) {
|
||||
verdict = "supplier_mismatch";
|
||||
} else {
|
||||
verdict = "ready_to_receive";
|
||||
}
|
||||
|
||||
const report: MatchReport = {
|
||||
verdict,
|
||||
po_id: poId,
|
||||
po_number: poNumber,
|
||||
po_status: poStatus,
|
||||
po_supplier_id: poSupplierId,
|
||||
po_supplier_name: poSupplierName,
|
||||
invoice_supplier_name: input.invoice.supplier_name ?? null,
|
||||
ordered_value: orderedValue,
|
||||
received_value: receivedValue,
|
||||
unmatched_po_lines: unmatchedPoLines,
|
||||
unmatched_invoice_lines: unmatchedInvoiceLines,
|
||||
line_comparisons: comparisons,
|
||||
};
|
||||
|
||||
// Dry-run short-circuit: return now.
|
||||
if (dryRun) {
|
||||
const attachmentFilename = input.attachment
|
||||
? buildSupplierInvoiceFilename(
|
||||
poSupplierName || input.invoice.supplier_name || "unknown",
|
||||
input.invoice.document_no,
|
||||
input.invoice.date,
|
||||
deriveExt(input.attachment.filename, input.attachment.content_type),
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
ok: true,
|
||||
mode: "dry_run",
|
||||
report,
|
||||
would_receive: verdict === "ready_to_receive",
|
||||
attachment_filename: attachmentFilename,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 8. Commit path ──────────────────────────────────────────────────
|
||||
if (verdict !== "ready_to_receive") {
|
||||
return {
|
||||
ok: false,
|
||||
mode: "committed",
|
||||
reason: verdict,
|
||||
message: `Refused to commit — verdict is "${verdict}". Resolve the mismatches and call again.`,
|
||||
report,
|
||||
};
|
||||
}
|
||||
|
||||
// Attach the invoice image first (idempotent enough — PB appends a
|
||||
// token, so a second call adds a duplicate). We don't try to dedupe.
|
||||
let attachmentEcho: MatchAndReceiveCommitted["attachment"] = null;
|
||||
if (input.attachment) {
|
||||
const bytes = Buffer.from(String(input.attachment.base64), "base64");
|
||||
const filename = buildSupplierInvoiceFilename(
|
||||
poSupplierName || input.invoice.supplier_name || "unknown",
|
||||
input.invoice.document_no,
|
||||
input.invoice.date,
|
||||
deriveExt(input.attachment.filename, input.attachment.content_type),
|
||||
);
|
||||
const file = new File([new Uint8Array(bytes)], filename, {
|
||||
type: input.attachment.content_type || "application/octet-stream",
|
||||
});
|
||||
const att = await addStockPurchaseAttachment({
|
||||
purchaseId: poId,
|
||||
file,
|
||||
});
|
||||
attachmentEcho = {
|
||||
filename,
|
||||
added: att.added,
|
||||
attachments: att.attachments,
|
||||
};
|
||||
}
|
||||
|
||||
const recv = await receiveStockPurchase(pb, poId);
|
||||
if (!recv.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
mode: "committed",
|
||||
reason: verdict,
|
||||
message: `Attachment uploaded but receive failed: ${recv.reason} — ${recv.message ?? ""}`,
|
||||
report,
|
||||
partial: { attachment_added: attachmentEcho?.added ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
mode: "committed",
|
||||
report,
|
||||
receive: {
|
||||
purchase_id: poId,
|
||||
purchase_number: poNumber,
|
||||
status: String((recv.purchase as Record<string, unknown>)?.status ?? "received"),
|
||||
received_value: Number((recv.purchase as Record<string, unknown>)?.received_value ?? orderedValue),
|
||||
ordered_value: orderedValue,
|
||||
lines_booked: recv.lines?.length ?? 0,
|
||||
},
|
||||
attachment: attachmentEcho,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
function emptyReport(input: MatchAndReceiveInput): MatchReport {
|
||||
return {
|
||||
verdict: "po_not_found",
|
||||
po_id: null,
|
||||
po_number: null,
|
||||
po_status: null,
|
||||
po_supplier_id: null,
|
||||
po_supplier_name: null,
|
||||
invoice_supplier_name: input.invoice.supplier_name ?? null,
|
||||
ordered_value: 0,
|
||||
received_value: 0,
|
||||
unmatched_po_lines: [],
|
||||
unmatched_invoice_lines: [],
|
||||
line_comparisons: [],
|
||||
};
|
||||
}
|
||||
|
||||
function deriveExt(filename: string, contentType: string): string {
|
||||
const fromName = filename.split(".").pop() ?? "";
|
||||
if (fromName && fromName.length <= 5) return fromName.toLowerCase();
|
||||
if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg";
|
||||
if (contentType.includes("png")) return "png";
|
||||
if (contentType.includes("pdf")) return "pdf";
|
||||
if (contentType.includes("webp")) return "webp";
|
||||
if (contentType.includes("heic")) return "heic";
|
||||
return "bin";
|
||||
}
|
||||
|
||||
function wrap(
|
||||
dryRun: boolean,
|
||||
report: MatchReport,
|
||||
message?: string | null,
|
||||
): MatchAndReceiveResult {
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
mode: "dry_run",
|
||||
report,
|
||||
would_receive: report.verdict === "ready_to_receive",
|
||||
attachment_filename: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
mode: "committed",
|
||||
reason: report.verdict,
|
||||
message: message ?? `Refused to commit — verdict is "${report.verdict}".`,
|
||||
report,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue