fix: let Purchase Receipt cancel defer to Frappe's linked-document check (#57592)

on_cancel pre-blocked cancellation with its own "Purchase Invoice is
already submitted" guard, duplicating the check Frappe already runs for any
submitted linked document. Drop the guard and the unused check_next_docstatus()
method it mirrored so the receipt defers to the framework: the Cancel All
Documents flow cancels the invoice first and then the receipt, and a direct
cancel is still rejected by Frappe's linked-document check.

Add a regression test that a direct cancel of a receipt with a submitted
invoice is rejected and rolls back, leaving no stray stock or GL entries.
This commit is contained in:
Jatin3128
2026-07-29 14:44:32 +05:30
committed by GitHub
parent 6b8b9d3644
commit cfe18e8427
2 changed files with 20 additions and 27 deletions

View File

@@ -423,31 +423,10 @@ class PurchaseReceipt(BuyingController):
row.received_qty,
)
def check_next_docstatus(self):
submit_rv = frappe.get_all(
"Purchase Invoice Item",
filters={"purchase_receipt": self.name, "docstatus": 1},
fields=["parent"],
as_list=True,
limit=1,
)
if submit_rv:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submit_rv[0][0]))
def on_cancel(self):
super().on_cancel()
self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order")
# Check if Purchase Invoice has been submitted against current Purchase Order
submitted = frappe.get_all(
"Purchase Invoice Item",
filters={"purchase_receipt": self.name, "docstatus": 1},
fields=["parent"],
as_list=True,
limit=1,
)
if submitted:
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
self.update_prevdoc_status()
self.update_billing_status()

View File

@@ -6131,17 +6131,31 @@ class TestPurchaseReceipt(ERPNextTestSuite):
# already received against this PO line, excluding pr2 itself, is pr1's 4
self.assertEqual(pr2.get_already_received_qty(po.name, po_detail), 4.0)
def test_check_next_docstatus_blocks_with_submitted_invoice(self):
"""check_next_docstatus must flag a submitted Purchase Invoice drawn from the receipt
covers the converted child-table get_all (Purchase Invoice Item, docstatus=1)."""
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
pr = make_purchase_receipt()
pi = make_purchase_invoice(pr.name)
pi.insert()
pi.submit()
with self.assertRaises(frappe.ValidationError) as cm:
pr.check_next_docstatus()
self.assertIn("is already submitted", str(cm.exception))
pr.reload()
status_before = pr.status
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
frappe.db.savepoint("before_blocked_cancel")
with self.assertRaises(frappe.LinkExistsError) as cm:
pr.cancel()
self.assertIn(pi.name, str(cm.exception))
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
pr.reload()
self.assertEqual(pr.docstatus, 1)
self.assertEqual(pr.status, status_before)
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
def create_asset_category_for_pr_test():