diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 28996602552..409e02f9eda 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -54,19 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite): po.save() self.assertEqual(po.items[1].qty, 1) - def test_purchase_order_negative_grand_total_blocked_by_default(self): + @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): - """A supplier change order can net to a negative grand total (credit owed).""" + """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/status_updater.py b/erpnext/controllers/status_updater.py index 4781eee012c..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,12 +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 Order", "Sales Invoice", "Delivery Note"] - ) or ( - not buying_negative_rate_allowed - and self.doctype in ["Purchase Order", "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( @@ -307,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 cff7fe23ee2..c13e899c1c4 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -157,19 +157,38 @@ class TestSalesOrder(ERPNextTestSuite): ) update_child_qty_rate("Sales Order", trans_item, so.name) - def test_sales_order_negative_grand_total_blocked_by_default(self): + @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): - """A subscription downgrade / change order can net to a negative grand total.""" + """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)