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
This commit is contained in:
Dave 2026-08-09 20:41:27 +02:00
parent 87cead1bcf
commit 9eef1fbd56
3 changed files with 151 additions and 5 deletions

View file

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

View file

@ -3573,6 +3573,7 @@ export async function POST(req: NextRequest) {
: undefined,
confirm,
mode,
partial: args.partial === true,
});
let status = 200;

View file

@ -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<string, unknown>)?.status ?? "received"),
status: String((recv.purchase as unknown as Record<string, unknown>)?.status ?? "received"),
received_value: Number(
(recv.purchase as Record<string, unknown>)?.received_value ?? resolved.ordered_value,
(recv.purchase as unknown as Record<string, unknown>)?.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,
};