diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index 6e7ebd1414d..ce9ce647db0 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -52,7 +52,10 @@ "free_item_rate", "column_break_42", "free_item_uom", + "round_free_qty", "is_recursive", + "recurse_for", + "apply_recursion_over", "section_break_23", "valid_from", "valid_upto", @@ -578,12 +581,34 @@ "fieldtype": "Select", "label": "Naming Series", "options": "PRLE-.####" + }, + { + "default": "0", + "fieldname": "round_free_qty", + "fieldtype": "Check", + "label": "Round Free Qty" + }, + { + "depends_on": "is_recursive", + "description": "Give free item for every N quantity", + "fieldname": "recurse_for", + "fieldtype": "Float", + "label": "Recurse Every (As Per Transaction UOM)", + "mandatory_depends_on": "is_recursive" + }, + { + "default": "0", + "depends_on": "is_recursive", + "description": "Qty for which recursion isn't applicable.", + "fieldname": "apply_recursion_over", + "fieldtype": "Float", + "label": "Apply Recursion Over (As Per Transaction UOM)" } ], "icon": "fa fa-gift", "idx": 1, "links": [], - "modified": "2022-09-16 16:00:38.356266", + "modified": "2022-10-13 19:05:35.056304", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 826d71b12e9..ed46d85e3a4 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -24,6 +24,7 @@ class PricingRule(Document): self.validate_applicable_for_selling_or_buying() self.validate_min_max_amt() self.validate_min_max_qty() + self.validate_recursion() self.cleanup_fields_value() self.validate_rate_or_discount() self.validate_max_discount() @@ -109,6 +110,18 @@ class PricingRule(Document): if self.min_amt and self.max_amt and flt(self.min_amt) > flt(self.max_amt): throw(_("Min Amt can not be greater than Max Amt")) + def validate_recursion(self): + if self.price_or_product_discount != "Product": + return + if self.free_item or self.same_item: + if flt(self.recurse_for) <= 0: + self.recurse_for = 1 + if self.is_recursive: + if flt(self.apply_recursion_over) > flt(self.min_qty): + throw(_("Min Qty should be greater than Recurse Over Qty")) + if flt(self.apply_recursion_over) < 0: + throw(_("Recurse Over Qty cannot be less than 0")) + def cleanup_fields_value(self): for logic_field in ["apply_on", "applicable_for", "rate_or_discount"]: fieldname = frappe.scrub(self.get(logic_field) or "") diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py index fbe567824f2..d27f65eba04 100644 --- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py @@ -710,6 +710,132 @@ class TestPricingRule(unittest.TestCase): item.delete() + def test_item_group_price_with_blank_uom_pricing_rule(self): + group = frappe.get_doc(doctype="Item Group", item_group_name="_Test Pricing Rule Item Group") + group.save() + properties = { + "item_code": "Item with Group Blank UOM", + "item_group": "_Test Pricing Rule Item Group", + "stock_uom": "Nos", + "sales_uom": "Box", + "uoms": [dict(uom="Box", conversion_factor=10)], + } + item = make_item(properties=properties) + + make_item_price("Item with Group Blank UOM", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Item with Group Blank UOM Rule", + "apply_on": "Item Group", + "item_groups": [ + { + "item_group": "_Test Pricing Rule Item Group", + } + ], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Rate", + "rate": 101, + "company": "_Test Company", + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice( + do_not_save=True, item_code="Item with Group Blank UOM", uom="Box", conversion_factor=10 + ) + si.selling_price_list = "_Test Price List" + si.save() + + # If UOM is blank consider it as stock UOM and apply pricing_rule on all UOM. + # rate is 101, Selling UOM is Box that have conversion_factor of 10 so 101 * 10 = 1010 + self.assertEqual(si.items[0].price_list_rate, 1010) + self.assertEqual(si.items[0].rate, 1010) + + si.delete() + + si = create_sales_invoice(do_not_save=True, item_code="Item with Group Blank UOM", uom="Nos") + si.selling_price_list = "_Test Price List" + si.save() + + # UOM is blank so consider it as stock UOM and apply pricing_rule on all UOM. + # rate is 101, Selling UOM is Nos that have conversion_factor of 1 so 101 * 1 = 101 + self.assertEqual(si.items[0].price_list_rate, 101) + self.assertEqual(si.items[0].rate, 101) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Item with Group Blank UOM"}).delete() + item.delete() + group.delete() + + def test_item_group_price_with_selling_uom_pricing_rule(self): + group = frappe.get_doc(doctype="Item Group", item_group_name="_Test Pricing Rule Item Group UOM") + group.save() + properties = { + "item_code": "Item with Group UOM other than Stock", + "item_group": "_Test Pricing Rule Item Group UOM", + "stock_uom": "Nos", + "sales_uom": "Box", + "uoms": [dict(uom="Box", conversion_factor=10)], + } + item = make_item(properties=properties) + + make_item_price("Item with Group UOM other than Stock", "_Test Price List", 100) + + pricing_rule_record = { + "doctype": "Pricing Rule", + "title": "_Test Item with Group UOM other than Stock Rule", + "apply_on": "Item Group", + "item_groups": [ + { + "item_group": "_Test Pricing Rule Item Group UOM", + "uom": "Box", + } + ], + "selling": 1, + "currency": "INR", + "rate_or_discount": "Rate", + "rate": 101, + "company": "_Test Company", + } + rule = frappe.get_doc(pricing_rule_record) + rule.insert() + + si = create_sales_invoice( + do_not_save=True, + item_code="Item with Group UOM other than Stock", + uom="Box", + conversion_factor=10, + ) + si.selling_price_list = "_Test Price List" + si.save() + + # UOM is Box so apply pricing_rule only on Box UOM. + # Selling UOM is Box and as both UOM are same no need to multiply by conversion_factor. + self.assertEqual(si.items[0].price_list_rate, 101) + self.assertEqual(si.items[0].rate, 101) + + si.delete() + + si = create_sales_invoice( + do_not_save=True, item_code="Item with Group UOM other than Stock", uom="Nos" + ) + si.selling_price_list = "_Test Price List" + si.save() + + # UOM is Box so pricing_rule won't apply as selling_uom is Nos. + # As Pricing Rule is not applied price of 100 will be fetched from Item Price List. + self.assertEqual(si.items[0].price_list_rate, 100) + self.assertEqual(si.items[0].rate, 100) + + si.delete() + rule.delete() + frappe.get_doc("Item Price", {"item_code": "Item with Group UOM other than Stock"}).delete() + item.delete() + group.delete() + def test_pricing_rule_for_different_currency(self): make_item("Test Sanitizer Item") @@ -943,6 +1069,45 @@ class TestPricingRule(unittest.TestCase): si.delete() rule.delete() + def test_pricing_rule_for_product_free_item_rounded_qty_and_recursion(self): + frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule") + test_record = { + "doctype": "Pricing Rule", + "title": "_Test Pricing Rule", + "apply_on": "Item Code", + "currency": "USD", + "items": [ + { + "item_code": "_Test Item", + } + ], + "selling": 1, + "rate": 0, + "min_qty": 3, + "max_qty": 7, + "price_or_product_discount": "Product", + "same_item": 1, + "free_qty": 1, + "round_free_qty": 1, + "is_recursive": 1, + "recurse_for": 2, + "company": "_Test Company", + } + frappe.get_doc(test_record.copy()).insert() + + # With pricing rule + so = make_sales_order(item_code="_Test Item", qty=5) + so.load_from_db() + self.assertEqual(so.items[1].is_free_item, 1) + self.assertEqual(so.items[1].item_code, "_Test Item") + self.assertEqual(so.items[1].qty, 2) + + so = make_sales_order(item_code="_Test Item", qty=7) + so.load_from_db() + self.assertEqual(so.items[1].is_free_item, 1) + self.assertEqual(so.items[1].item_code, "_Test Item") + self.assertEqual(so.items[1].qty, 4) + test_dependencies = ["Campaign"] diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 4c78d7261d0..bb54b23e263 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -127,6 +127,12 @@ def _get_pricing_rules(apply_on, args, values): values["variant_of"] = args.variant_of elif apply_on_field == "item_group": item_conditions = _get_tree_conditions(args, "Item Group", child_doc, False) + if args.get("uom", None): + item_conditions += ( + " and ({child_doc}.uom='{item_uom}' or IFNULL({child_doc}.uom, '')='')".format( + child_doc=child_doc, item_uom=args.get("uom") + ) + ) conditions += get_other_conditions(conditions, values, args) warehouse_conditions = _get_tree_conditions(args, "Warehouse", "`tabPricing Rule`") @@ -627,9 +633,13 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): qty = pricing_rule.free_qty or 1 if pricing_rule.is_recursive: - transaction_qty = args.get("qty") if args else doc.total_qty + transaction_qty = ( + args.get("qty") if args else doc.total_qty + ) - pricing_rule.apply_recursion_over if transaction_qty: - qty = flt(transaction_qty) * qty + qty = flt(transaction_qty) * qty / pricing_rule.recurse_for + if pricing_rule.round_free_qty: + qty = round(qty) free_item_data_args = { "item_code": free_item, diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 370b13bb98d..5c1311d68a9 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -221,7 +221,7 @@ class TestAsset(AssetSetup): asset.precision("gross_purchase_amount"), ) pro_rata_amount, _, _ = asset.get_pro_rata_amt( - asset.finance_books[0], 9000, add_months(get_last_day(purchase_date), 1), date + asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date ) pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount")) self.assertEquals(accumulated_depr_amount, 18000.00 + pro_rata_amount) @@ -283,7 +283,7 @@ class TestAsset(AssetSetup): self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold") pro_rata_amount, _, _ = asset.get_pro_rata_amt( - asset.finance_books[0], 9000, add_months(get_last_day(purchase_date), 1), date + asset.finance_books[0], 9000, get_last_day(add_months(purchase_date, 1)), date ) pro_rata_amount = flt(pro_rata_amount, asset.precision("gross_purchase_amount")) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 7fecb18fad4..dd957c72ac6 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1404,7 +1404,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if (!r.exc && r.message) { me._set_values_for_item_list(r.message); if(item) me.set_gross_profit(item); - if(me.frm.doc.apply_discount_on) me.frm.trigger("apply_discount_on") + if (me.frm.doc.apply_discount_on) me.frm.trigger("apply_discount_on") } } }); @@ -1577,6 +1577,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe for (let key in pr_row) { row_to_modify[key] = pr_row[key]; } + this.frm.script_manager.copy_from_first_row("items", row_to_modify, ["expense_account", "income_account"]); }); // free_item_data is a temporary variable