From 4862ae42d5a29456298f3c79802e1743c99470d5 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 21 May 2025 14:27:26 +0530 Subject: [PATCH 1/5] fix: handle cumulative breach for monthly and annual - better method names --- erpnext/controllers/budget_controller.py | 59 ++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 6db7cfa0cc2..49e63aea6e6 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -4,7 +4,7 @@ import frappe from frappe import _, qb from frappe.query_builder import Criterion from frappe.query_builder.functions import IfNull, Sum -from frappe.utils import flt, fmt_money, get_link_to_form +from frappe.utils import comma_and, flt, fmt_money, get_link_to_form from erpnext.accounts.doctype.budget.budget import BudgetError, get_accumulated_monthly_budget from erpnext.accounts.utils import get_fiscal_year @@ -87,7 +87,8 @@ class BudgetValidation: self.get_ordered_amount(key) self.get_requested_amount(key) - self.handle_action(key, v) + # Pre-emptive validation before hitting ledger + self.handle_actions(key, v) # Validation happens after submit for Purchase Order and # Material Request and so will be included in the query @@ -96,7 +97,7 @@ class BudgetValidation: v.current_actual_exp_amount = sum([x.debit - x.credit for x in v.get("gl_to_process", [])]) self.get_actual_expense(key) - self.handle_action(key, v) + self.handle_actions(key, v) def get_child_nodes(self, budget_against, dimension): lft, rgt = frappe.db.get_all( @@ -328,7 +329,7 @@ class BudgetValidation: ) self.execute_action(config.action_for_monthly, _msg) - def handle_action(self, key, v_map): + def handle_purchase_order_overlimit(self, key, v_map): self.handle_individual_doctype_action( key, frappe._dict( @@ -344,6 +345,8 @@ class BudgetValidation: v_map.current_ordered_amount, v_map.accumulated_monthly_budget, ) + + def handle_material_request_overlimit(self, key, v_map): self.handle_individual_doctype_action( key, frappe._dict( @@ -359,6 +362,8 @@ class BudgetValidation: v_map.current_requested_amount, v_map.accumulated_monthly_budget, ) + + def handle_actual_expense_overlimit(self, key, v_map): self.handle_individual_doctype_action( key, frappe._dict( @@ -375,6 +380,49 @@ class BudgetValidation: v_map.accumulated_monthly_budget, ) + def handle_actions(self, key, v_map): + self.handle_purchase_order_overlimit(key, v_map) + self.handle_material_request_overlimit(key, v_map) + self.handle_actual_expense_overlimit(key, v_map) + # PO + MR + Actual Expense + self.handle_cumulative_overlimit(key, v_map) + + def handle_cumulative_overlimit(self, key, v_map): + self.handle_cumulative_overlimit_for_monthly(key, v_map) + self.handle_cumulative_overlimit_for_annual(key, v_map) + + def budget_applicable_for(self, budget_doc) -> str: + doctypes = [] + if budget_doc.applicable_on_purchase_order: + doctypes.append("Purchase Order") + if budget_doc.applicable_on_material_request: + doctypes.append("Material Request") + if budget_doc.applicable_on_booking_actual_expenses: + doctypes.append("Actual Expense") + return comma_and(doctypes) + + def handle_cumulative_overlimit_for_monthly(self, key, v_map): + current_amt = ( + v_map.current_ordered_amount + v_map.current_requested_amount + v_map.current_actual_exp_amount + ) + monthly_diff = ( + v_map.ordered_amount + v_map.requested_amount + v_map.actual_expense + current_amt + ) - v_map.accumulated_monthly_budget + if monthly_diff > 0: + currency = frappe.get_cached_value("Company", self.company, "default_currency") + _msg = _( + "Accumulated Monthly Budget for Account {0} against {1} {2} is {3}. It will be collectively ({4}) exceeded by {5}" + ).format( + frappe.bold(key[2]), + frappe.bold(frappe.unscrub(key[0])), + frappe.bold(key[1]), + frappe.bold(fmt_money(v_map.accumulated_montly_budget, currency=currency)), + self.budget_applicable_for(v_map.budget_doc), + frappe.bold(fmt_money(monthly_diff, currency=currency)), + ) + self.stop(_msg) + + def handle_cumulative_overlimit_for_annual(self, key, v_map): current_amt = ( v_map.current_ordered_amount + v_map.current_requested_amount + v_map.current_actual_exp_amount ) @@ -384,12 +432,13 @@ class BudgetValidation: if total_diff > 0: currency = frappe.get_cached_value("Company", self.company, "default_currency") _msg = _( - "Annual Budget for Account {0} against {1} {2} is {3}. It will be exceeded by {4}" + "Annual Budget for Account {0} against {1} {2} is {3}. It will be collectively ({4}) exceeded by {5}" ).format( frappe.bold(key[2]), frappe.bold(frappe.unscrub(key[0])), frappe.bold(key[1]), frappe.bold(fmt_money(v_map.budget_amount, currency=currency)), + self.budget_applicable_for(v_map.budget_doc), frappe.bold(fmt_money(total_diff, currency=currency)), ) self.stop(_msg) From 45368f983ba521d1ff91c837a21bccde1e2064d8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 May 2025 13:47:02 +0530 Subject: [PATCH 2/5] refactor: control actions for cumulative expense --- erpnext/accounts/doctype/budget/budget.json | 36 +++++++++++++++++++-- erpnext/accounts/doctype/budget/budget.py | 3 ++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 404b66359d8..7f066568517 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -28,6 +28,10 @@ "applicable_on_booking_actual_expenses", "action_if_annual_budget_exceeded", "action_if_accumulated_monthly_budget_exceeded", + "control_action_for_cumulative_expense_section", + "applicable_on_cumulative_expense", + "action_if_annual_exceeded_on_cumulative_expense", + "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "section_break_21", "accounts" ], @@ -202,12 +206,39 @@ "print_hide": 1, "read_only": 1, "set_only_once": 1 + }, + { + "fieldname": "control_action_for_cumulative_expense_section", + "fieldtype": "Section Break", + "label": "Control Action for Cumulative Expense" + }, + { + "default": "0", + "description": "(Purchase Order + Material Request + Actual Expense)", + "fieldname": "applicable_on_cumulative_expense", + "fieldtype": "Check", + "label": "Applicable on Cumulative Expense" + }, + { + "depends_on": "eval:doc.applicable_on_cumulative_expense == 1", + "fieldname": "action_if_annual_exceeded_on_cumulative_expense", + "fieldtype": "Select", + "label": "Action if Anual Budget Exceeded on Cumulative Expense", + "options": "\nStop\nWarn\nIgnore" + }, + { + "depends_on": "eval:doc.applicable_on_cumulative_expense == 1", + "fieldname": "action_if_accumulated_monthly_exceeded_on_cumulative_expense", + "fieldtype": "Select", + "label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense", + "options": "\nStop\nWarn\nIgnore" } ], + "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-27 13:06:42.675933", + "modified": "2025-05-22 13:46:28.510566", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", @@ -231,8 +262,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 8e58f294505..2362ddf731b 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -36,11 +36,14 @@ class Budget(Document): action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"] + action_if_accumulated_monthly_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_annual_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] 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"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check + applicable_on_cumulative_expense: DF.Check applicable_on_material_request: DF.Check applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] From 3eb07fba2aee3336f93c2ae4cdfbaf74a605b3f2 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 May 2025 13:54:04 +0530 Subject: [PATCH 3/5] refactor: use cumulative control action on new controller --- erpnext/controllers/budget_controller.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 49e63aea6e6..ef1f6075991 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -183,6 +183,9 @@ class BudgetValidation: bud.applicable_on_booking_actual_expenses, bud.action_if_annual_budget_exceeded, bud.action_if_accumulated_monthly_budget_exceeded, + bud.applicable_on_cumulative_expense, + bud.action_if_annual_exceeded_on_cumulative_expense, + bud.action_if_accumulated_monthly_exceeded_on_cumulative_expense, bud_acc.account, bud_acc.budget_amount, ) @@ -388,8 +391,9 @@ class BudgetValidation: self.handle_cumulative_overlimit(key, v_map) def handle_cumulative_overlimit(self, key, v_map): - self.handle_cumulative_overlimit_for_monthly(key, v_map) - self.handle_cumulative_overlimit_for_annual(key, v_map) + if v_map.budget_doc.applicable_on_cumulative_expense: + self.handle_cumulative_overlimit_for_monthly(key, v_map) + self.handle_cumulative_overlimit_for_annual(key, v_map) def budget_applicable_for(self, budget_doc) -> str: doctypes = [] @@ -420,7 +424,10 @@ class BudgetValidation: self.budget_applicable_for(v_map.budget_doc), frappe.bold(fmt_money(monthly_diff, currency=currency)), ) - self.stop(_msg) + + self.execute_action( + v_map.budget_doc.action_if_accumulated_monthly_exceeded_on_cumulative_expense, _msg + ) def handle_cumulative_overlimit_for_annual(self, key, v_map): current_amt = ( @@ -441,4 +448,4 @@ class BudgetValidation: self.budget_applicable_for(v_map.budget_doc), frappe.bold(fmt_money(total_diff, currency=currency)), ) - self.stop(_msg) + self.execute_action(v_map.budget_doc.action_if_annual_exceeded_on_cumulative_expense, _msg) From f077f60344782ead1607d07f3e8b7b864acf43cf Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 May 2025 14:03:43 +0530 Subject: [PATCH 4/5] refactor(test): utlity method to set cumulative actions --- erpnext/accounts/doctype/budget/test_budget.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 32fd27e0858..f9349a28793 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -479,6 +479,15 @@ def make_budget(**args): args.action_if_accumulated_monthly_budget_exceeded_on_po or "Warn" ) + if args.applicable_on_cumulative_expense: + budget.applicable_on_cumulative_expense = 1 + budget.action_if_annual_exceeded_on_cumulative_expense = ( + args.action_if_annual_exceeded_on_cumulative_expense or "Warn" + ) + budget.action_if_accumulated_monthly_exceeded_on_cumulative_expense = ( + args.action_if_accumulated_monthly_exceeded_on_cumulative_expense or "Warn" + ) + budget.insert() budget.submit() From 5a9b272f8480956bd60bbec4f180e012a59a5158 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Thu, 22 May 2025 15:04:17 +0530 Subject: [PATCH 5/5] test: cumulative actions for budget --- .../accounts/doctype/budget/test_budget.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index f9349a28793..707a52e84ad 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -380,6 +380,44 @@ class TestBudget(ERPNextTestSuite): self.assertRaises(BudgetError, jv.submit) + def test_action_for_cumulative_limit(self): + set_total_expense_zero(nowdate(), "cost_center") + + budget = make_budget(budget_against="Cost Center", applicable_on_cumulative_expense=True) + + accumulated_limit = get_accumulated_monthly_budget( + budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount + ) + + jv = make_journal_entry( + "_Test Account Cost for Goods Sold - _TC", + "_Test Bank - _TC", + accumulated_limit - 1, + "_Test Cost Center - _TC", + posting_date=nowdate(), + ) + jv.submit() + + frappe.db.set_value( + "Budget", budget.name, "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "Stop" + ) + po = create_purchase_order( + transaction_date=nowdate(), qty=1, rate=accumulated_limit + 1, do_not_submit=True + ) + po.set_missing_values() + + self.assertRaises(BudgetError, po.submit) + + frappe.db.set_value( + "Budget", budget.name, "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "Ignore" + ) + po.submit() + + budget.load_from_db() + budget.cancel() + po.cancel() + jv.cancel() + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project":