From 9eef1fbd56630b2ca70fc115f0a3a1b029252ab5 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 9 Aug 2026 20:41:27 +0200 Subject: [PATCH] feat(mcp): add partial=true flag to PO invoice matching for buyer-side mark-and-flip When the supplier ships a partial invoice (BREATHER on PO26080502STK was not invoiced this round, but the other 7 lines were), the buyer wants to: - mark and flip what we can confirm came in - leave the rest for the buyer to reconcile manually Adding a new 'partial: true' flag to stock_purchases.match_and_receive_from_invoice that: - widens the verdict gate to allow 'unmatched_po_lines' and 'cost_mismatch' to proceed when partial=true (the buyer is explicitly accepting these) - passes per-line quantity_received:0 overrides for unmatched PO lines into the existing receiveStockPurchase(items) override path, so only matched lines get booked into stock_ledger - surfaces 'skipped_po_line_ids' in the response so the buyer sees exactly what was held back - leaves the customer-PO path unchanged (every matched line is stamped, partial semantics only apply to stock purchases for now) Hard stops regardless of partial: qty_mismatch on matched lines, unmatched_invoice_lines, supplier_mismatch, wrong_status. Test coverage: +1 dispatch test (partial flag forwarding) plus the existing 'partial: false' assertion on the dry-run dispatch test. Live-validated on PO26080502STK + invoice WR248939 (G-Man #1, 7 lines): - 6 matched CIRCLIP/KM/MB lines booked, qty_received bumped - BREATHER 3/4 line skipped via qty_received=0, surfaced in skipped_po_line_ids for manual reconciliation - PO status flipped sent -> received --- src/app/api/mcp/route.test.ts | 72 ++++++++++++++++ src/app/api/mcp/route.ts | 1 + ...stock-purchase-invoice-matching-service.ts | 83 +++++++++++++++++-- 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 3012bf2..5cf0d92 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -601,6 +601,7 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); attachment: undefined, confirm: false, mode: "invoice", + partial: false, }); const body = await res.json(); @@ -751,6 +752,77 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); expect(res.status).toBe(400); }); + it("tools/call stock_purchases.match_and_receive_from_invoice forwards partial=true to the service (mark-and-flip workflow)", async () => { + // The buyer-side "mark and flip what we can confirm came in, leave + // the rest" workflow. The service skips unmatched PO lines + // (BREATHER wasn't invoiced this round) and books the rest. + matchAndReceiveFromPoDocumentMock.mockResolvedValueOnce({ + ok: true, + mode: "committed", + report: { + verdict: "unmatched_po_lines", + po_kind: "stock", + po_id: "sp-1", + po_number: "PO26080502STK", + po_status: "received", + po_supplier_id: "sup-1", + po_supplier_name: "G-Man Traders", + document_no: "WR248939", + invoice_supplier_name: "G-Man Traders", + mode: "invoice", + ordered_value: 3482.55, + received_value: 3482.55, + unmatched_po_lines: [ + { id: "breather-id", description: "BREATHER 3/4", quantity: 10, cost_price: 0 }, + ], + unmatched_invoice_lines: [], + line_comparisons: [], + }, + receive: { + po_kind: "stock", + purchase_id: "sp-1", + purchase_number: "PO26080502STK", + status: "received", + received_value: 3482.55, + ordered_value: 3482.55, + lines_booked: 6, + }, + attachment: null, + attachment_skipped_reason: null, + skipped_po_line_ids: ["breather-id"], + }); + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 77, + params: { + name: "stock_purchases.match_and_receive_from_invoice", + arguments: { + po_reference: "PO26080502STK", + document: { + document_no: "WR248939", + date: "2026-08-06", + supplier_name: "G-Man Traders", + lines: [ + { code: "471-080", description: "471-080", quantity: 10, unit_price: 5.75 }, + ], + }, + partial: true, + confirm: true, + }, + }, + }), + ); + expect(res.status).toBe(200); + expect(matchAndReceiveFromPoDocumentMock).toHaveBeenCalledWith( + expect.objectContaining({ partial: true }), + ); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + expect(data.receive.lines_booked).toBe(6); + expect(data.skipped_po_line_ids).toEqual(["breather-id"]); + }); + it("tools/call stock_purchases.match_and_receive_from_invoice accepts the legacy `invoice` arg shape (back-compat)", async () => { matchAndReceiveFromPoDocumentMock.mockResolvedValueOnce({ ok: true, diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c74e2a3..010c090 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -3573,6 +3573,7 @@ export async function POST(req: NextRequest) { : undefined, confirm, mode, + partial: args.partial === true, }); let status = 200; diff --git a/src/lib/server/stock-purchase-invoice-matching-service.ts b/src/lib/server/stock-purchase-invoice-matching-service.ts index 70fd626..6c5cc65 100644 --- a/src/lib/server/stock-purchase-invoice-matching-service.ts +++ b/src/lib/server/stock-purchase-invoice-matching-service.ts @@ -134,6 +134,18 @@ export type MatchAndReceiveInput = { * `"grn"` — delivery-note / goods-received-note mode: no price check, TODO note stamped. */ mode?: MatchMode; + /** + * Partial commit: book only the lines that match cleanly, leave + * unmatched PO lines (e.g. items the supplier didn't invoice this + * round) for the buyer to reconcile manually. Default `false` — + * block the commit on any unmatched PO line. + * + * This is the buyer-side "mark and flip what we can confirm came + * in, leave the rest" workflow. Cost-mismatched lines ARE + * committed (the goods ARE received, even if the price disagrees); + * only PO lines with no invoice counterpart are skipped. + */ + partial?: boolean; }; export type MatchVerdict = @@ -255,6 +267,10 @@ export type MatchAndReceiveCommitted = { /** When attachment upload was skipped because the underlying record * has no place to store it (e.g. customer order without a matched quote). */ attachment_skipped_reason: string | null; + /** For `partial: true` stock commits, the PO line ids that were + * intentionally skipped (no invoice counterpart). These stay + * `quantity_received: 0` for the buyer to reconcile manually. */ + skipped_po_line_ids: string[]; }; export type MatchAndReceiveBlocked = { @@ -858,6 +874,11 @@ export async function matchAndReceiveFromPoDocument( unmatched_po_lines: unmatchedPoLines, unmatched_invoice_lines: unmatchedInvoiceLines, line_comparisons: comparisons, + // Only populated on `po_not_found` (the textual suggester in + // phase 5 scans all open POs to give the buyer a shortlist when + // the supplier typed the PO ref wrong). On the matched path + // it's empty — the buyer already knows which PO they're on. + suggested_orders: [], }; if (dryRun) { @@ -880,7 +901,17 @@ export async function matchAndReceiveFromPoDocument( } // ── 6. Commit path ────────────────────────────────────────────────── - if (verdict !== "ready_to_receive") { + // + // The gate allows partial=true to bypass `unmatched_po_lines` + // (supplier didn't bill for some items this round — buyer will + // reconcile manually) and `cost_mismatch` (we accept invoice prices + // as authoritative for the receipt; PO prices can be backfilled + // separately). qty_mismatch, unmatched_invoice_lines, supplier_mismatch, + // and wrong_status remain hard stops even with partial=true. + const partialBypassOk = + input.partial === true && + (verdict === "unmatched_po_lines" || verdict === "cost_mismatch"); + if (verdict !== "ready_to_receive" && !partialBypassOk) { return { ok: false, mode: "committed", @@ -935,7 +966,37 @@ export async function matchAndReceiveFromPoDocument( } if (resolved.kind === "stock") { - const recv = await receiveStockPurchase(pb, resolved.po_id); + // ── Partial-commit support ───────────────────────────────────── + // + // When `partial: true`, book only the lines that matched cleanly + // (qty > 0 with all-or-nothing pairing). Skip PO lines with no + // invoice counterpart (e.g. supplier didn't bill for them this + // round) by passing `quantity_received: 0` in the override array. + // + // The stock_ledger still records the matched lines correctly; the + // PO header status flips to `received` as before. Unmatched PO + // lines retain their original `quantity_received: 0` and the + // buyer can reconcile them manually (or via a later invoice). + const isPartial = input.partial === true; + const overrides: Array<{ id: string; quantity_received: number }> = []; + if (isPartial) { + const usedPoLineIds = new Set( + comparisons + .map((c) => c.po_line_id) + .filter((id): id is string => Boolean(id)), + ); + for (const pl of resolved.lines) { + if (!usedPoLineIds.has(pl.id)) { + overrides.push({ id: pl.id, quantity_received: 0 }); + } + } + } + + const recv = await receiveStockPurchase( + pb, + resolved.po_id, + isPartial ? { items: overrides } : {}, + ); if (!recv.ok) { return { ok: false, @@ -954,15 +1015,23 @@ export async function matchAndReceiveFromPoDocument( po_kind: "stock", purchase_id: resolved.po_id, purchase_number: resolved.po_number, - status: String((recv.purchase as Record)?.status ?? "received"), + status: String((recv.purchase as unknown as Record)?.status ?? "received"), received_value: Number( - (recv.purchase as Record)?.received_value ?? resolved.ordered_value, + (recv.purchase as unknown as Record)?.received_value ?? resolved.ordered_value, ), ordered_value: resolved.ordered_value, lines_booked: recv.lines?.length ?? 0, }, attachment: attachmentEcho, attachment_skipped_reason: null, + // Surface the unmatched-but-untouched PO line ids so the buyer + // can reconcile them manually. (For non-partial commits the + // commit would have been blocked, so this array is empty.) + skipped_po_line_ids: isPartial + ? resolved.lines + .filter((p) => !comparisons.some((c) => c.po_line_id === p.id)) + .map((p) => p.id) + : [], }; } @@ -1019,6 +1088,10 @@ export async function matchAndReceiveFromPoDocument( lines_booked: linesBooked, }, attachment: attachmentEcho, + // Customer PO commits don't currently support partial — every + // matched line is stamped. Unmatched PO items stay in their + // pre-existing state. + skipped_po_line_ids: [], // When the customer PO has no matched quote we silently skip the // attachment. Surface this so the buyer / agent knows the file // isn't on disk — they can attach it manually via `orders.add_attachment` @@ -1328,7 +1401,7 @@ async function scoreCandidate( po_status: poStatus, supplier_name: poSupplierName, score: round2(score), - line_matches, + line_matches: lineMatches, unmatched_invoice_lines: unmatchedInvoice, unmatched_po_lines: unmatchedPo, };