diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index ea1bc3195fc..617604582b5 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -108,6 +108,14 @@ class TestSalesInvoice(ERPNextTestSuite): si.save() self.assertEqual(si.items[0].qty, 1) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self): + """allow_negative_rates_for_items must not bypass the >=0 guard for a non-return + invoice, since invoices post to the GL (unlike Sales Order).""" + si = create_sales_invoice(qty=1, rate=100, do_not_save=True) + si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + self.assertRaises(frappe.ValidationError, si.save) + def test_timestamp_change(self): w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0]) w.docstatus = 0 diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 2cc53d6ad95..409e02f9eda 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite): po.save() self.assertEqual(po.items[1].qty, 1) + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0}) + def test_purchase_order_negative_grand_total_blocked_without_setting(self): + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) + self.assertRaises(frappe.ValidationError, po.save) + + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1}) + def test_purchase_order_negative_grand_total_allowed_with_setting(self): + """Use a negative rate to represent a credit while order quantities remain positive.""" + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()}) + po.save() + po.submit() + self.assertEqual(po.docstatus, 1) + self.assertTrue(po.base_grand_total < 0) + + @ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1}) + def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self): + po = create_purchase_order(qty=1, rate=100, do_not_save=True) + po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100}) + self.assertRaises(frappe.ValidationError, po.save) + def test_purchase_order_zero_qty(self): po = create_purchase_order(qty=0, do_not_save=True) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index dd4f51025f7..a3ec879bbea 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -241,6 +241,23 @@ class AccountsController(TransactionBase): ) frappe.msgprint(msg) + def is_negative_grand_total_allowed(self) -> bool: + """Return True if this document may save with a negative grand total. + + Sales Order and Purchase Order never post to the GL, so a negative + total is safe there whenever the user has explicitly opted into + negative rates via Selling/Buying Settings. Every other + AccountsController doctype (invoices, delivery notes, receipts, + quotations, ...) keeps relying on the `is_return` escape hatch only. + """ + if self.doctype == "Sales Order": + return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items")) + + if self.doctype == "Purchase Order": + return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items")) + + return False + def validate(self): if not self.get("is_return") and not self.get("is_debit_note"): self.validate_qty_is_not_zero() @@ -290,7 +307,8 @@ class AccountsController(TransactionBase): self.calculate_taxes_and_totals() if not self.meta.get_field("is_return") or not self.is_return: - self.validate_value("base_grand_total", ">=", 0) + if not self.is_negative_grand_total_allowed(): + self.validate_value("base_grand_total", ">=", 0) validate_return(self) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index ddea50fb7ff..786bf5ccd60 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -264,6 +264,9 @@ class StatusUpdater(Document): def validate_qty(self): """Validates qty at row level""" + selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note") + buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt") + for args in self.status_updater: if "target_ref_field" not in args or args.get("validate_qty") is False: # if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation @@ -291,11 +294,8 @@ class StatusUpdater(Document): if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"): frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) - if ( - not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"] - ) or ( - not buying_negative_rate_allowed - and self.doctype in ["Purchase Invoice", "Purchase Receipt"] + if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or ( + not buying_negative_rate_allowed and self.doctype in buying_doctypes ): if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0: frappe.throw( @@ -306,7 +306,7 @@ class StatusUpdater(Document): frappe.bold(_("`Allow Negative rates for Items`")), get_link_to_form( "Selling Settings" - if self.doctype in ["Sales Invoice", "Delivery Note"] + if self.doctype in selling_doctypes else "Buying Settings" ), ), diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 57d6fde087b..c13e899c1c4 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -157,6 +157,38 @@ class TestSalesOrder(ERPNextTestSuite): ) update_child_qty_rate("Sales Order", trans_item, so.name) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) + def test_sales_order_negative_grand_total_blocked_without_setting(self): + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + self.assertRaises(frappe.ValidationError, so.save) + + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_order_negative_grand_total_allowed_with_setting(self): + """Use a negative rate to represent a credit while order quantities remain positive.""" + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150}) + so.save() + so.submit() + self.assertEqual(so.docstatus, 1) + self.assertTrue(so.base_grand_total < 0) + + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) + def test_sales_order_negative_rate_error_links_to_selling_settings(self): + so = make_sales_order(qty=1, rate=100, do_not_save=True) + so.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -10}) + so.save() + + with self.assertRaises(frappe.ValidationError) as error: + so.submit() + + self.assertIn("selling-settings", str(error.exception)) + + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1}) + def test_sales_order_negative_rate_setting_does_not_allow_negative_quantity(self): + so = make_sales_order(qty=-1, rate=100, do_not_save=True) + self.assertRaises(frappe.NonNegativeError, so.save) + @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1}) def test_sales_order_qty(self): so = make_sales_order(qty=1, do_not_save=True)