Merge pull request #57923 from frappe/mergify/bp/version-16-hotfix/pr-57560

fix: handling negative grand total (backport #57560)
This commit is contained in:
Mihir Kandoi
2026-08-09 16:59:48 +05:30
committed by GitHub
5 changed files with 87 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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