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:
parent
87cead1bcf
commit
9eef1fbd56
3 changed files with 151 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3573,6 +3573,7 @@ export async function POST(req: NextRequest) {
|
|||
: undefined,
|
||||
confirm,
|
||||
mode,
|
||||
partial: args.partial === true,
|
||||
});
|
||||
|
||||
let status = 200;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue