From bd88356a8aba0a00f133011fac249b010e42ccd4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 30 Oct 2025 20:12:12 +0530 Subject: [PATCH] feat: budget for multiple fiscal year --- erpnext/accounts/doctype/budget/budget.json | 76 ++-- erpnext/accounts/doctype/budget/budget.py | 403 +++++++++++------- .../accounts/doctype/budget/test_budget.py | 121 ++++-- .../budget_distribution.json | 6 +- 4 files changed, 388 insertions(+), 218 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 643f4f78d8f..849d4a5e927 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -13,14 +13,17 @@ "cost_center", "project", "account", - "fiscal_year", "column_break_3", "amended_from", + "from_fiscal_year", + "to_fiscal_year", "distribution_type", "allocation_frequency", - "budget_start_date", - "budget_end_date", "budget_amount", + "section_break_nwug", + "distribute_equally", + "section_break_fpdt", + "budget_distribution", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -37,8 +40,6 @@ "applicable_on_cumulative_expense", "action_if_annual_exceeded_on_cumulative_expense", "action_if_accumulated_monthly_exceeded_on_cumulative_expense", - "section_break_fpdt", - "budget_distribution", "section_break_kkan", "revision_of" ], @@ -51,6 +52,7 @@ "in_standard_filter": 1, "label": "Budget Against", "options": "\nCost Center\nProject", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -60,6 +62,7 @@ "in_standard_filter": 1, "label": "Company", "options": "Company", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -69,7 +72,8 @@ "in_global_search": 1, "in_standard_filter": 1, "label": "Cost Center", - "options": "Cost Center" + "options": "Cost Center", + "read_only_depends_on": "eval: doc.revision_of" }, { "depends_on": "eval:doc.budget_against == 'Project'", @@ -77,16 +81,8 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Project", - "options": "Project" - }, - { - "fieldname": "fiscal_year", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Fiscal Year", - "options": "Fiscal Year", - "reqd": 1 + "options": "Project", + "read_only_depends_on": "eval: doc.revision_of" }, { "fieldname": "column_break_3", @@ -237,6 +233,7 @@ "fieldtype": "Link", "label": "Account", "options": "Account", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -245,18 +242,7 @@ "fieldtype": "Select", "label": "Distribution Type", "options": "Amount\nPercent", - "reqd": 1 - }, - { - "fieldname": "budget_start_date", - "fieldtype": "Date", - "label": "Budget Start Date", - "reqd": 1 - }, - { - "fieldname": "budget_end_date", - "fieldtype": "Date", - "label": "Budget End Date", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -264,14 +250,15 @@ "fieldname": "allocation_frequency", "fieldtype": "Select", "label": "Allocation Frequency", - "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount", - "options": "Company:company:default_currency", + "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -284,13 +271,40 @@ "label": "Revision Of", "no_copy": 1, "read_only": 1 + }, + { + "default": "1", + "fieldname": "distribute_equally", + "fieldtype": "Check", + "label": "Distribute Equally" + }, + { + "fieldname": "section_break_nwug", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "from_fiscal_year", + "fieldtype": "Link", + "label": "From Fiscal Year", + "options": "Fiscal Year", + "read_only_depends_on": "eval: doc.revision_of", + "reqd": 1 + }, + { + "fieldname": "to_fiscal_year", + "fieldtype": "Link", + "label": "To Fiscal Year", + "options": "Fiscal Year", + "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-10-29 03:06:52.730795", + "modified": "2025-10-30 19:07:51.022844", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 58595e4f708..e0eaff8b4a3 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff -from frappe.utils.data import get_first_day +from frappe.utils.data import get_first_day, nowdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -45,7 +45,7 @@ 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", "Date Range"] + 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 @@ -54,15 +54,15 @@ class Budget(Document): budget_against: DF.Literal["", "Cost Center", "Project"] budget_amount: DF.Currency budget_distribution: DF.Table[BudgetDistribution] - budget_end_date: DF.Date - budget_start_date: DF.Date company: DF.Link cost_center: DF.Link | None + distribute_equally: DF.Check distribution_type: DF.Literal["Amount", "Percent"] - fiscal_year: DF.Link + from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None revision_of: DF.Data | None + to_fiscal_year: DF.Link # end: auto-generated types def validate(self): @@ -81,25 +81,38 @@ class Budget(Document): if not account: return - existing_budget = frappe.db.get_all( - "Budget", - filters={ - "docstatus": ("<", 2), - "company": self.company, - budget_against_field: budget_against, - "fiscal_year": self.fiscal_year, - "account": account, - "name": ("!=", self.name), - }, - fields=["name", "account"], + from_start, _ = frappe.get_cached_value( + "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"] + ) + _, to_end = frappe.get_cached_value( + "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"] + ) + + existing_budget = frappe.db.sql( + f""" + SELECT name, account + FROM `tabBudget` + WHERE + docstatus < 2 + AND company = %s + AND {budget_against_field} = %s + AND account = %s + AND name != %s + AND ( + (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s + AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s + ) + """, + (self.company, budget_against, account, self.name, to_end, from_start), + as_dict=True, ) if existing_budget: d = existing_budget[0] frappe.throw( - _("Another Budget record '{0}' already exists against {1} '{2}' and account '{3}'").format( - d.name, self.budget_against, budget_against, d.account - ), + _( + "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." + ).format(d.name, self.budget_against, budget_against, d.account), DuplicateBudgetError, ) @@ -153,75 +166,99 @@ class Budget(Document): if self.revision_of: return - self.set("budget_distribution", []) - if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): + if not self.should_regenerate_budget_distribution(): return - start = getdate(self.budget_start_date) - end = getdate(self.budget_end_date) - freq = self.allocation_frequency + self.set("budget_distribution", []) - months = month_diff(end, start) - if freq == "Monthly": - total_periods = months - elif freq == "Quarterly": - total_periods = months // 3 + (1 if months % 3 else 0) - elif freq == "Half-Yearly": - total_periods = months // 6 + (1 if months % 6 else 0) - else: - total_periods = end.year - start.year + 1 + self.set_budget_date_range() + periods = self.get_budget_periods() + total_periods = len(periods) + row_percent = 100 / total_periods if total_periods else 0 - if self.distribution_type == "Amount": - per_row = flt(self.budget_amount / total_periods, 2) - else: - per_row = flt(100 / total_periods, 2) - - assigned = 0 - current = start - - while current <= end: + for start_date, end_date in periods: row = self.append("budget_distribution", {}) + row.start_date = start_date + row.end_date = end_date + self.add_allocated_amount(row, row_percent) - if freq == "Monthly": - row.start_date = get_first_day(current) - row.end_date = get_last_day(current) - current = add_months(current, 1) - elif freq == "Quarterly": - month = ((current.month - 1) // 3) * 3 + 1 - quarter_start = date(current.year, month, 1) - quarter_end = get_last_day(add_months(quarter_start, 2)) - row.start_date = quarter_start - row.end_date = min(quarter_end, end) - current = add_months(quarter_start, 3) - elif freq == "Half-Yearly": - half = 1 if current.month <= 6 else 2 - half_start = date(current.year, 1, 1) if half == 1 else date(current.year, 7, 1) - half_end = date(current.year, 6, 30) if half == 1 else date(current.year, 12, 31) - row.start_date = half_start - row.end_date = min(half_end, end) - current = add_months(half_start, 6) - else: # Yearly - year_start = date(current.year, 1, 1) - year_end = date(current.year, 12, 31) - row.start_date = year_start - row.end_date = min(year_end, end) - current = date(current.year + 1, 1, 1) + def should_regenerate_budget_distribution(self): + """Check whether budget distribution should be recalculated.""" + old_doc = self.get_doc_before_save() if not self.is_new() else None - if self.distribution_type == "Amount": - if len(self.budget_distribution) == total_periods: - row.amount = flt(self.budget_amount - assigned) + if not self.budget_distribution: + return True - else: - row.amount = per_row - assigned += per_row - row.percent = flt(row.amount * 100 / self.budget_amount) - else: - if len(self.budget_distribution) == total_periods: - row.percent = flt(100 - assigned) - else: - row.percent = per_row - assigned += per_row - row.amount = flt(row.percent * self.budget_amount / 100) + if old_doc: + changed_fields = [ + "from_fiscal_year", + "to_fiscal_year", + "allocation_frequency", + "distribute_equally", + ] + for field in changed_fields: + if old_doc.get(field) != self.get(field): + return True + + return bool(self.distribute_equally) + + def set_budget_date_range(self): + """Set budget start and end dates based on selected fiscal years.""" + from_fiscal_year = frappe.db.get_value( + "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + to_fiscal_year = frappe.db.get_value( + "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True + ) + + self.budget_start_date = from_fiscal_year.year_start_date + self.budget_end_date = to_fiscal_year.year_end_date + + def get_budget_periods(self): + """Return list of (start_date, end_date) tuples based on frequency.""" + frequency = self.allocation_frequency + periods = [] + + start_date = getdate(self.budget_start_date) + end_date = getdate(self.budget_end_date) + + while start_date <= end_date: + period_start = get_first_day(start_date) + period_end = self.get_period_end(period_start, frequency) + period_end = min(period_end, end_date) + + periods.append((period_start, period_end)) + start_date = add_months(period_start, self.get_month_increment(frequency)) + + return periods + + def get_period_end(self, start_date, frequency): + """Return the correct end date for a given frequency.""" + if frequency == "Monthly": + return get_last_day(start_date) + elif frequency == "Quarterly": + return get_last_day(add_months(start_date, 2)) + elif frequency == "Half-Yearly": + return get_last_day(add_months(start_date, 5)) + else: # Yearly + return get_last_day(add_months(start_date, 11)) + + def get_month_increment(self, frequency): + """Return how many months to move forward for the next period.""" + return { + "Monthly": 1, + "Quarterly": 3, + "Half-Yearly": 6, + "Yearly": 12, + }.get(frequency, 1) + + def add_allocated_amount(self, row, row_percent): + if not self.distribute_equally: + row.amount = 0 + row.percent = 0 + else: + row.amount = flt(self.budget_amount * row_percent / 100, 2) + row.percent = flt(row_percent, 3) def validate_expense_against_budget(args, expense_amount=0): @@ -229,17 +266,31 @@ def validate_expense_against_budget(args, expense_amount=0): if not frappe.db.count("Budget", cache=True): return - if not args.fiscal_year: - args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0] + posting_date = getdate(args.get("posting_date")) + posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0] + year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year) + + budget_exists = frappe.db.sql( + """ + select name + from `tabBudget` + where company = %s + and docstatus = 1 + and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s + and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s + limit 1 + """, + (args.company, year_end_date, year_start_date), + ) + + if not budget_exists: + return if args.get("company"): frappe.flags.exception_approver_role = frappe.get_cached_value( "Company", args.get("company"), "exception_budget_approver_role" ) - if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}): - return - if not args.account: args.account = args.get("expense_account") @@ -284,22 +335,36 @@ def validate_expense_against_budget(args, expense_amount=0): budget_records = frappe.db.sql( f""" - select - b.name, b.{budget_against} as budget_against, b.budget_amount, - ifnull(b.applicable_on_material_request, 0) as for_material_request, - ifnull(applicable_on_purchase_order, 0) as for_purchase_order, - ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses, - b.action_if_annual_budget_exceeded, b.action_if_accumulated_monthly_budget_exceeded, - b.action_if_annual_budget_exceeded_on_mr, b.action_if_accumulated_monthly_budget_exceeded_on_mr, - b.action_if_annual_budget_exceeded_on_po, b.action_if_accumulated_monthly_budget_exceeded_on_po - from + SELECT + b.name, + b.{budget_against} AS budget_against, + b.budget_amount, + b.from_fiscal_year, + b.to_fiscal_year, + IFNULL(b.applicable_on_material_request, 0) AS for_material_request, + IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order, + IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses, + b.action_if_annual_budget_exceeded, + b.action_if_accumulated_monthly_budget_exceeded, + b.action_if_annual_budget_exceeded_on_mr, + b.action_if_accumulated_monthly_budget_exceeded_on_mr, + b.action_if_annual_budget_exceeded_on_po, + b.action_if_accumulated_monthly_budget_exceeded_on_po + FROM `tabBudget` b - where - b.fiscal_year=%s - and b.account=%s and b.docstatus=1 + WHERE + b.company = %s + AND b.docstatus = 1 + AND ( + %s BETWEEN + (SELECT year_start_date FROM `tabFiscal Year` WHERE name = b.from_fiscal_year) + AND + (SELECT year_end_date FROM `tabFiscal Year` WHERE name = b.to_fiscal_year) + ) + AND b.account = %s {condition} - """, - (args.fiscal_year, args.account), + """, + (args.company, args.posting_date, args.account), as_dict=True, ) # nosec @@ -313,6 +378,7 @@ def validate_budget_records(args, budget_records, expense_amount): yearly_action, monthly_action = get_actions(args, budget) args["for_material_request"] = budget.for_material_request args["for_purchase_order"] = budget.for_purchase_order + args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year if yearly_action in ("Stop", "Warn"): compare_expense_with_budget( @@ -384,7 +450,7 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_ def get_expense_breakup(args, currency, budget_against): - msg = "
{{ _('Total Expenses booked through') }} -