From 57f9faa15af93c80acab7ea43b9d960a660964e4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 6 Nov 2025 14:03:09 +0530 Subject: [PATCH] fix: validate existing expenses when revising or modifying budget amounts --- erpnext/accounts/doctype/budget/budget.json | 22 ++--- erpnext/accounts/doctype/budget/budget.py | 54 +++++++++++-- .../accounts/doctype/budget/test_budget.py | 81 ++++++++----------- 3 files changed, 95 insertions(+), 62 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 9565f16e673..a17919c52b0 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -19,7 +19,7 @@ "to_fiscal_year", "budget_start_date", "budget_end_date", - "allocation_frequency", + "distribution_frequency", "budget_amount", "section_break_nwug", "distribute_equally", @@ -238,15 +238,6 @@ "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, - { - "default": "Monthly", - "fieldname": "allocation_frequency", - "fieldtype": "Select", - "label": "Allocation Frequency", - "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", - "read_only_depends_on": "eval: doc.revision_of", - "reqd": 1 - }, { "fieldname": "budget_amount", "fieldtype": "Currency", @@ -302,13 +293,22 @@ "fieldtype": "Date", "hidden": 1, "label": "Budget End Date" + }, + { + "default": "Monthly", + "fieldname": "distribution_frequency", + "fieldtype": "Select", + "label": "Distribution Frequency", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", + "read_only_depends_on": "eval: doc.revision_of", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-05 01:00:46.470251", + "modified": "2025-11-06 10:36:35.565701", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ffbdb3c0955..5bbf8dfc0bd 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -45,7 +45,6 @@ class Budget(Document): action_if_annual_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"] - allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -59,6 +58,7 @@ class Budget(Document): company: DF.Link cost_center: DF.Link | None distribute_equally: DF.Check + distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None @@ -75,6 +75,7 @@ class Budget(Document): self.validate_account() self.set_null_value() self.validate_applicable_for() + self.validate_existing_expenses() def validate_fiscal_year(self): if self.from_fiscal_year: @@ -94,12 +95,14 @@ class Budget(Document): self.budget_start_date = frappe.get_cached_value( "Fiscal Year", self.from_fiscal_year, "year_start_date" ) - if self.to_fiscal_year: self.budget_end_date = frappe.get_cached_value( "Fiscal Year", self.to_fiscal_year, "year_end_date" ) + if self.budget_start_date > self.budget_end_date: + frappe.throw(_("From Fiscal Year cannot be greater than To Fiscal Year")) + def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) @@ -179,6 +182,47 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 + def validate_existing_expenses(self): + if self.is_new() and self.revision_of: + return + + args = frappe._dict( + { + "company": self.company, + "account": self.account, + "budget_start_date": self.budget_start_date, + "budget_end_date": self.budget_end_date, + "budget_against_field": frappe.scrub(self.budget_against), + "budget_against_doctype": frappe.unscrub(self.budget_against), + } + ) + + args[args.budget_against_field] = self.get(args.budget_against_field) + + if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): + args.is_tree = True + else: + args.is_tree = False + + actual_spent = get_actual_expense(args) + + if actual_spent > self.budget_amount: + frappe.throw( + _( + "Spending for Account {0} ({1}) between {2} and {3} " + "has already exceeded the new allocated budget. " + "Spent: {4}, Budget: {5}" + ).format( + frappe.bold(self.account), + frappe.bold(self.company), + frappe.bold(self.budget_start_date), + frappe.bold(self.budget_end_date), + frappe.bold(frappe.utils.fmt_money(actual_spent)), + frappe.bold(frappe.utils.fmt_money(self.budget_amount)), + ), + title=_("Budget Limit Exceeded"), + ) + def before_save(self): self.allocate_budget() @@ -215,7 +259,7 @@ class Budget(Document): "from_fiscal_year", "to_fiscal_year", "budget_amount", - "allocation_frequency", + "distribution_frequency", "distribute_equally", ] for field in changed_fields: @@ -226,7 +270,7 @@ class Budget(Document): def get_budget_periods(self): """Return list of (start_date, end_date) tuples based on frequency.""" - frequency = self.allocation_frequency + frequency = self.distribution_frequency periods = [] start_date = getdate(self.budget_start_date) @@ -279,7 +323,7 @@ class Budget(Document): if flt(abs(total_amount - self.budget_amount), 2) > 0.10: frappe.throw( - _("Total distributed amount {0} must equal Budget Amount {1}").format( + _("Total distributed amount {0} must be equal to Budget Amount {1}").format( flt(total_amount, 2), self.budget_amount ) ) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 537942e7c8e..dd31cdc636f 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -446,24 +446,23 @@ class TestBudget(ERPNextTestSuite): po.cancel() jv.cancel() - def test_distribution_date_validation(self): - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = self.account - budget.budget_amount = 100000 - - start = getdate("2025-04-10") - end = getdate("2025-04-05") - - budget.append( - "budget_distribution", + def test_fiscal_year_validation(self): + frappe.get_doc( { - "start_date": start, - "end_date": end, - "amount": 50000, - }, + "doctype": "Fiscal Year", + "year": "2100", + "year_start_date": "2100-04-01", + "year_end_date": "2101-03-31", + "companies": [{"company": "_Test Company"}], + } + ).insert(ignore_permissions=True) + + budget = make_budget( + budget_against="Cost Center", + from_fiscal_year="2100", + to_fiscal_year="2099", + do_not_save=True, + submit_budget=False, ) with self.assertRaises(frappe.ValidationError): @@ -473,35 +472,14 @@ class TestBudget(ERPNextTestSuite): budget = make_budget( budget_against="Cost Center", applicable_on_cumulative_expense=True, + distribute_equally=0, + budget_amount=12000, do_not_save=False, - submit_budget=True, + submit_budget=False, ) - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = ("_Test Account Cost for Goods Sold - _TC",) - budget.budget_amount = 12000 - budget.start_date = getdate("2025-04-01") - budget.end_date = getdate("2025-06-30") - - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-04-01"), - "end_date": getdate("2025-04-30"), - "amount": 6000, - }, - ) - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-05-01"), - "end_date": getdate("2025-06-30"), - "amount": 5000, - }, - ) + for row in budget.budget_distribution: + row.amount = 2000 with self.assertRaises(frappe.ValidationError): budget.save() @@ -531,6 +509,7 @@ class TestBudget(ERPNextTestSuite): self.assertEqual(old_budget.docstatus, 2) def test_revision_preserves_distribution(self): + set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center - _TC") budget = make_budget( budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True ) @@ -634,6 +613,7 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again budget_against = budget_against_CC or "_Test Cost Center - _TC" fiscal_year = get_fiscal_year(nowdate())[0] + fiscal_year_start_date, fiscal_year_end_date = get_fiscal_year(nowdate())[1:3] args = frappe._dict( { @@ -644,12 +624,21 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again "from_fiscal_year": fiscal_year, "to_fiscal_year": fiscal_year, "budget_against_field": budget_against_field, + "budget_start_date": fiscal_year_start_date, + "budget_end_date": fiscal_year_end_date, } ) if not args.get(budget_against_field): args[budget_against_field] = budget_against + args.budget_against_doctype = frappe.unscrub(budget_against_field) + + if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): + args.is_tree = True + else: + args.is_tree = False + existing_expense = get_actual_expense(args) if existing_expense: @@ -714,8 +703,8 @@ def make_budget(**args): else: budget.cost_center = cost_center or "_Test Cost Center - _TC" - budget.from_fiscal_year = fiscal_year - budget.to_fiscal_year = fiscal_year + budget.from_fiscal_year = args.from_fiscal_year or fiscal_year + budget.to_fiscal_year = args.to_fiscal_year or fiscal_year budget.company = "_Test Company" budget.account = "_Test Account Cost for Goods Sold - _TC" budget.budget_amount = args.budget_amount or 200000 @@ -724,7 +713,7 @@ def make_budget(**args): budget.action_if_accumulated_monthly_budget_exceeded = "Ignore" budget.budget_against = budget_against - budget.allocation_frequency = "Monthly" + budget.distribution_frequency = "Monthly" budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: