diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index afc08c0e4d6..79385db3a24 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -396,6 +396,9 @@ class PurchaseInvoice(BuyingController): self.party_account_currency = account.account_currency def check_on_hold_or_closed_status(self): + if self.get("is_return"): + return + check_list = [] for d in self.get("items"): diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index ae9b8442c34..50d6f76e3dc 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2609,6 +2609,39 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): self.assertEqual(row.serial_no, "\n".join(serial_nos[:2])) self.assertEqual(row.rejected_serial_no, serial_nos[2]) + def test_purchase_invoice_return_against_closed_purchase_order(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + po = create_purchase_order(qty=2, rate=100) + + invoices = [] + for _ in range(2): + pi = make_pi_from_po(po.name) + pi.items[0].qty = 1 + pi.submit() + invoices.append(pi) + + make_return_doc("Purchase Invoice", invoices[0].name).submit() + + po.reload() + po.update_status("Closed") + + # a debit note against a closed Purchase Order should still go through, + # the same way a Sales Invoice return does against a closed Sales Order + debit_note = make_return_doc("Purchase Invoice", invoices[1].name) + debit_note.submit() + + self.assertEqual(debit_note.docstatus, 1) + self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed") + + # cancelling the debit note runs the same check on the closed order + debit_note.reload() + debit_note.cancel() + + # a regular invoice against the closed order must still be blocked + blocked_pi = make_pi_from_po(po.name) + self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pi.save) + def test_make_pr_and_pi_from_po(self): from erpnext.assets.doctype.asset.test_asset import create_asset_category diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 1bbec4b5196..a4323365201 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -755,7 +755,7 @@ class BuyingController(SubcontractingController): if po and po_item_rows: po_obj = frappe.get_doc("Purchase Order", po) - if po_obj.status in ["Closed", "Cancelled"]: + if po_obj.status == "Cancelled" or (po_obj.status == "Closed" and not self.get("is_return")): frappe.throw( _("{0} {1} is cancelled or closed").format(_("Purchase Order"), po), frappe.InvalidStatusError, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 25c6fd987f5..f7df8ecd9f3 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -372,6 +372,9 @@ class PurchaseReceipt(BuyingController): # Check for Closed status def check_on_hold_or_closed_status(self): + if self.get("is_return"): + return + check_list = [] for d in self.get("items"): if d.meta.get_field("purchase_order") and d.purchase_order and d.purchase_order not in check_list: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 40cf324010d..f30158b4f51 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -708,6 +708,43 @@ class TestPurchaseReceipt(FrappeTestCase): update_purchase_receipt_status(pr.name, "Closed") self.assertEqual(frappe.db.get_value("Purchase Receipt", pr.name, "status"), "Closed") + def test_purchase_return_against_closed_purchase_order(self): + from erpnext.buying.doctype.purchase_order.purchase_order import ( + make_purchase_receipt as make_pr_from_po, + ) + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + po = create_purchase_order(qty=2, rate=100) + + receipts = [] + for _ in range(2): + pr = make_pr_from_po(po.name) + pr.items[0].qty = pr.items[0].received_qty = 1 + pr.submit() + receipts.append(pr) + + first_return = make_return_doc("Purchase Receipt", receipts[0].name) + first_return.submit() + + po.reload() + po.update_status("Closed") + + # a return against a closed Purchase Order should still go through, + # the same way a Delivery Note return does against a closed Sales Order + second_return = make_return_doc("Purchase Receipt", receipts[1].name) + second_return.submit() + + self.assertEqual(second_return.docstatus, 1) + self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed") + + # cancelling the return runs the same check on the closed order + second_return.cancel() + + # a regular receipt against the closed order must still be blocked + blocked_pr = make_pr_from_po(po.name) + self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pr.save) + def test_pr_billing_status(self): """Flow: 1. PO -> PR1 -> PI