diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 7a796d090ff..dcf99b922a5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2646,6 +2646,282 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1) self.assertRaises(frappe.ValidationError, so.save) +<<<<<<< HEAD +======= + @ERPNextTestSuite.change_settings( + "Stock Settings", {"enable_stock_reservation": 1, "use_serial_batch_fields": 0} + ) + def test_product_bundle_reservation(self): + pb_item = make_item("Product Bundle Item", {"is_stock_item": 0}) + simple_item = make_item("Simple Item", {"is_stock_item": 1}) + sb_item = make_item( + "Serial Batch Item", + { + "is_stock_item": 1, + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BAT-TSBIFRM-.#####", + "serial_no_series": "SN-TSBIFRM-.#####", + }, + ) + make_product_bundle(pb_item.name, [simple_item.name, sb_item.name]) + + warehouse = "_Test Warehouse - _TC" + + make_stock_entry( + item_code=simple_item.name, + target=warehouse, + qty=10, + ) + + # two different stock entries on purpose to get two batches + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + + so = make_sales_order(item_code=pb_item.name, do_not_submit=1) + so.reserve_stock = 1 + for item in so.packed_items: + item.reserve_stock = 1 + so.submit() + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_batch_nos_details, + get_sre_reserved_qty_for_voucher_detail_no, + get_sre_reserved_serial_nos_details, + ) + + for item in so.packed_items: + self.assertEqual( + get_sre_reserved_qty_for_voucher_detail_no(item.item_code, "Sales Order", so.name, item.name), + item.qty, + ) + + sre_serial_nos = list(get_sre_reserved_serial_nos_details(sb_item.name, warehouse).keys()) + sre_batch_nos = list(get_sre_reserved_batch_nos_details(sb_item.name, warehouse).keys()) + + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn.save() + + self.assertTrue(dn.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + dn.items[0].qty = 5 + dn.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", dn.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = dn.company + sabb_doc.save() + dn.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + dn1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn1.save() + + self.assertTrue(dn1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(dn1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(dn1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + + dn.cancel() + + # test the same thing with sales invoice as well + + si = make_sales_invoice(so.name) + si.update_stock = 1 + si.save() + + self.assertTrue(si.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + si.items[0].qty = 5 + si.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", si.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = si.company + sabb_doc.save() + si.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + si1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + si1.save() + + self.assertTrue(si1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(si1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(si1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + + def test_sales_team_contribution_follows_grant_commission(self): + """Sales-person allocation tracks the grant-commission-eligible amount, not the gross total. + + The Item "Grant Commission" flag includes an item in both Sales Partner and Sales Person + commission, so each sales person's allocated_amount is a share of + amount_eligible_for_commission rather than net_total. + """ + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 0) + try: + so = make_sales_order( + do_not_save=True, + item_list=[ + {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 100}, + { + "item_code": "_Test FG Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + ], + ) + so.append( + "sales_team", + {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60, "commission_rate": 10}, + ) + so.append( + "sales_team", + {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40, "commission_rate": 0}, + ) + so.save() + + self.assertEqual(so.net_total, 2000) + self.assertEqual(so.amount_eligible_for_commission, 1000) # only the grant_commission item + + first, second = so.sales_team + # allocation follows the eligible amount (1000), not net_total (2000) + self.assertEqual(first.allocated_amount, 600) + self.assertEqual(first.incentives, 60) # 600 * 10% + self.assertEqual(second.allocated_amount, 400) + self.assertEqual(second.incentives, 0) + finally: + # grant_commission defaults to 1 for both items; restore + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 1) + + def test_sales_team_allocated_percentage_must_total_100(self): + with self.subTest("partial allocation is rejected"): + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) + self.assertRaises(frappe.ValidationError, so.save) + + with self.subTest("allocation totalling 100 is accepted"): + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) + so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40}) + so.save() + self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100) + + with self.subTest("floating-point drift in the total is tolerated"): + # 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point + so = make_sales_order(do_not_save=True) + for sales_person, percentage in ( + ("_Test Sales Person", 10.0), + ("_Test Sales Person 1", 58.02), + ("_Test Sales Person 2", 31.98), + ): + so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage}) + so.save() + + def test_sales_team_disabled_sales_person_rejected(self): + frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0) + try: + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 100}) + self.assertRaises(frappe.ValidationError, so.save) + finally: + frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 1) + + def test_sales_partner_commission(self): + """Sales Partner commission: total_commission = amount_eligible_for_commission * rate / 100.""" + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.save() + + self.assertEqual(so.amount_eligible_for_commission, 1000) + self.assertEqual(so.total_commission, 70) # 1000 * 7% + + with self.subTest("commission rate above 100 is rejected"): + so.commission_rate = 101 + self.assertRaises(frappe.ValidationError, so.save) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + + def test_commission_fields_not_copied_on_duplicate(self): + """Commission rate/amount fields are no_copy; only the sales partner carries to a copy.""" + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.save() + self.assertEqual(so.total_commission, 70) + + # ignore_no_copy=False mirrors UI "Duplicate"/amend, which honour no_copy + duplicate = frappe.copy_doc(so, ignore_no_copy=False) + self.assertEqual(duplicate.sales_partner, "_Test Sales Partner India - 1") + self.assertFalse(duplicate.commission_rate) + self.assertFalse(duplicate.total_commission) + self.assertFalse(duplicate.amount_eligible_for_commission) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + + def test_commission_rate_carried_through_mapper(self): + """commission_rate is no_copy, but Make Delivery Note / Sales Invoice still carries it.""" + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice + + original = frappe.db.get_value("Item", "_Test Item", "grant_commission") + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.submit() + + # carried to the mapped (unsaved) documents even though the field is no_copy + self.assertEqual(make_delivery_note(so.name).commission_rate, 7) + self.assertEqual(make_sales_invoice(so.name).commission_rate, 7) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", original) + +>>>>>>> 4afba94d1c (test: sales team allocation totalling 100 in floating point) def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")):