diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index 4a895b8b7e0..2bb639d0e1e 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -3,6 +3,7 @@ from unittest.mock import patch import frappe from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals +from erpnext.selling.doctype.quotation.test_quotation import make_quotation from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.tests.utils import ERPNextTestSuite @@ -59,3 +60,82 @@ class TestTaxesAndTotals(ERPNextTestSuite): self.assertEqual(so.rounding_adjustment, 0) self.assertEqual(so.base_rounded_total, 0) self.assertEqual(so.base_rounding_adjustment, 0) + + def test_calculate_margin_amount_type(self): + """When rate exceeds price_list_rate and no pricing rules, margin type is set to 'Amount'.""" + so = make_sales_order(do_not_save=True) + item = so.items[0] + item.qty = 2 + item.price_list_rate = 100.0 + item.rate = 120.0 # rate > price_list_rate → implicit Amount margin + item.pricing_rules = "" + item.margin_type = None + item.margin_rate_or_amount = 0 + + calculate_taxes_and_totals(so) + + self.assertEqual(item.margin_type, "Amount") + self.assertEqual(item.margin_rate_or_amount, 20.0) + # rate_with_margin should equal the explicit rate + self.assertEqual(item.rate_with_margin, 120.0) + + def test_calculate_margin_percentage_type(self): + """Percentage margin should add a fraction of price_list_rate to derive rate_with_margin.""" + so = make_sales_order(do_not_save=True) + item = so.items[0] + item.qty = 1 + item.price_list_rate = 200.0 + item.rate = 200.0 + item.pricing_rules = "" + item.margin_type = "Percentage" + item.margin_rate_or_amount = 10 # 10% margin + + calculate_taxes_and_totals(so) + + # rate_with_margin = price_list_rate * (1 + margin_rate / 100) + expected_rate_with_margin = 200.0 * 1.10 + self.assertAlmostEqual(item.rate_with_margin, expected_rate_with_margin, places=2) + + def test_filter_rows_excludes_alternative_items(self): + """Quotation totals must not include rows marked as is_alternative.""" + qo = make_quotation(qty=5, rate=100, do_not_save=True) + # Append an alternative item that should be excluded from the net total + qo.append( + "items", + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 500, + "is_alternative": 1, + }, + ) + + calculate_taxes_and_totals(qo) + + # Only the first (non-alternative) item should contribute: 5 × 100 = 500 + self.assertEqual(qo.net_total, 500.0) + self.assertEqual(qo.grand_total, 500.0) + + def test_calculate_total_net_weight(self): + """total_net_weight must equal the sum of total_weight across all item rows.""" + so = make_sales_order(do_not_save=True) + so.items[0].qty = 3 + so.items[0].rate = 50 + so.items[0].total_weight = 6.0 # set directly so no item master lookup needed + + calculate_taxes_and_totals(so) + + self.assertEqual(so.total_net_weight, 6.0) + + def test_set_discount_amount_exceeds_grand_total_throws(self): + """Discount amount larger than grand total must raise a ValidationError.""" + so = make_sales_order(do_not_save=True) + so.items[0].qty = 1 + so.items[0].rate = 100 + so.apply_discount_on = "Grand Total" + so.discount_amount = 200 # more than the 100 grand total + # _action must be set to trigger the validation path + so._action = "save" + + self.assertRaises(frappe.ValidationError, calculate_taxes_and_totals, so) diff --git a/erpnext/controllers/tests/test_transaction_base.py b/erpnext/controllers/tests/test_transaction_base.py index 3348892ae21..e2ed9f3fca5 100644 --- a/erpnext/controllers/tests/test_transaction_base.py +++ b/erpnext/controllers/tests/test_transaction_base.py @@ -1,6 +1,7 @@ import frappe from erpnext.tests.utils import ERPNextTestSuite +from erpnext.utilities.transaction_base import validate_uom_is_integer class TestUtils(ERPNextTestSuite): @@ -92,3 +93,73 @@ class TestUtils(ERPNextTestSuite): doc.reset_default_field_value("to_warehouse", "items", "t_warehouse") self.assertEqual(doc.from_warehouse, None) self.assertEqual(doc.to_warehouse, "Warehouse 2") + + def test_validate_posting_time_invalid(self): + """An invalid posting_time string must raise a ValidationError.""" + doc = frappe.get_doc({"doctype": "Stock Entry"}) + doc.set_posting_time = 1 + doc.posting_time = "not-a-time" + + self.assertRaises(frappe.ValidationError, doc.validate_posting_time) + + def test_validate_posting_time_auto_set(self): + """When set_posting_time is falsy, posting_date and posting_time are replaced with now.""" + from frappe.utils import getdate, nowdate + + doc = frappe.get_doc({"doctype": "Stock Entry"}) + doc.set_posting_time = 0 + doc.posting_date = "2000-01-01" + doc.posting_time = "00:00:00" + + doc.validate_posting_time() + + # Both fields must have been refreshed to the current date/time + self.assertEqual(doc.posting_date, nowdate()) + # posting_time should look like HH:MM:SS (not the old midnight value) + self.assertNotEqual(doc.posting_time, "00:00:00") + + def test_validate_uom_is_integer_raises_for_fraction(self): + """Fractional qty in a whole-number UOM must raise UOMMustBeIntegerError.""" + from erpnext.utilities.transaction_base import UOMMustBeIntegerError + + # Nos is seeded as a whole-number UOM in test fixtures + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "company": "_Test Company", + "items": [ + { + "item_code": "_Test Item", + "uom": "Nos", + "qty": 1.5, + "t_warehouse": "_Test Warehouse - _TC", + "basic_rate": 100, + } + ], + } + ) + + self.assertRaises(UOMMustBeIntegerError, validate_uom_is_integer, se, "uom", "qty") + + def test_validate_uom_is_integer_passes_for_whole_number(self): + """Integer qty in a whole-number UOM must NOT raise any error.""" + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "company": "_Test Company", + "items": [ + { + "item_code": "_Test Item", + "uom": "Nos", + "qty": 3, + "t_warehouse": "_Test Warehouse - _TC", + "basic_rate": 100, + } + ], + } + ) + + # Should complete without raising + validate_uom_is_integer(se, "uom", "qty")