From 8ebd1fd029b757adc4d94abda9969949bde81253 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 30 Dec 2025 01:44:38 +0530 Subject: [PATCH 01/12] refactor: budget variance report --- .../budget_variance_report.js | 2 +- .../budget_variance_report.py | 510 ++++++++---------- 2 files changed, 229 insertions(+), 283 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js index c74450191aa..989dfca042d 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js @@ -6,7 +6,7 @@ frappe.query_reports["Budget Variance Report"] = { formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname.includes(__("variance"))) { + if (column.fieldname && column.fieldname.startsWith("variance")) { if (data[column.fieldname] < 0) { value = "" + value + ""; } else if (data[column.fieldname] > 0) { diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index db42d23a839..7ed62baee45 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -1,14 +1,12 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - - -import datetime +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt import frappe from frappe import _ -from frappe.utils import flt, formatdate +from frappe.utils import add_months, flt, formatdate -from erpnext.controllers.trends import get_period_date_ranges, get_period_month_ranges +from erpnext.accounts.utils import get_fiscal_year +from erpnext.controllers.trends import get_period_date_ranges def execute(filters=None): @@ -16,60 +14,243 @@ def execute(filters=None): filters = {} columns = get_columns(filters) - if filters.get("budget_against_filter"): - dimensions = filters.get("budget_against_filter") - else: - dimensions = get_cost_centers(filters) - period_month_ranges = get_period_month_ranges(filters["period"], filters["from_fiscal_year"]) - cam_map = get_dimension_account_month_map(filters) + budget_records = fetch_budget_accounts(filters) + budget_map = build_budget_map(budget_records, filters) + data = get_data_from_budget_map(budget_map, filters) + + return columns, data + + +def get_fiscal_year_dates(fiscal_year): + fy = frappe.get_doc("Fiscal Year", fiscal_year) + return fy.year_start_date, fy.year_end_date + + +def get_month_list(start_date, end_date): + months = [] + current = start_date + + while current <= end_date: + months.append(current.strftime("%Y-%m")) + current = add_months(current, 1) + + return months + + +def fetch_budget_accounts(filters): + budget_against_field = frappe.scrub(filters["budget_against"]) + budget_records = frappe.db.sql( + f""" + SELECT + b.name, + b.account, + b.{budget_against_field} AS dimension, + b.budget_amount, + b.from_fiscal_year, + b.to_fiscal_year, + b.budget_start_date, + b.budget_end_date + FROM + `tabBudget` b + WHERE + b.company = %s + AND b.docstatus = 1 + AND b.budget_against = %s + AND %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year + AND %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year + """, + (filters.company, filters.budget_against, filters.from_fiscal_year, filters.to_fiscal_year), + as_dict=True, + ) + return budget_records + + +def build_budget_map(budget_records, filters): + budget_map = {} + + for budget in budget_records: + actual_amt = get_actual_details(budget.dimension, filters) + budget_map.setdefault(budget.dimension, {}) + budget_map[budget.dimension].setdefault(budget.account, {}) + budget_distributions = get_budget_distributions(budget) + + for row in budget_distributions: + fiscal_year = get_fiscal_year(row.start_date)[0] + month = row.start_date.strftime("%B") + budget_map[budget.dimension][budget.account].setdefault(fiscal_year, {}) + budget_map[budget.dimension][budget.account][fiscal_year].setdefault(month, {}) + budget_map[budget.dimension][budget.account][fiscal_year][month] = { + "budget": row.amount, + "actual": 0, + } + for ad in actual_amt.get(budget.account, []): + print("AD RECORD", ad, "\n\n\n") + if ad.month_name == month and ad.fiscal_year == fiscal_year: + budget_map[budget.dimension][budget.account][fiscal_year][month]["actual"] += flt( + ad.debit + ) - flt(ad.credit) + + return budget_map + + +def get_budget_distributions(budget): + return frappe.db.sql( + """ + SELECT start_date, end_date, amount, percent + FROM `tabBudget Distribution` + WHERE parent = %s + ORDER BY start_date ASC + """, + (budget.name,), + as_dict=True, + ) + + +def get_actual_details(name, filters): + budget_against = frappe.scrub(filters.get("budget_against")) + cond = "" + + if filters.get("budget_against") == "Cost Center" and name: + cc_lft, cc_rgt = frappe.db.get_value("Cost Center", name, ["lft", "rgt"]) + cond = f""" + and lft >= "{cc_lft}" + and rgt <= "{cc_rgt}" + """ + + ac_details = frappe.db.sql( + f""" + select + gl.account, + gl.debit, + gl.credit, + gl.fiscal_year, + MONTHNAME(gl.posting_date) as month_name, + b.{budget_against} as budget_against + from + `tabGL Entry` gl, + `tabBudget` b + where + b.docstatus = 1 + and b.account=gl.account + and b.{budget_against} = gl.{budget_against} + and gl.fiscal_year between %s and %s + and gl.is_cancelled = 0 + and b.{budget_against} = %s + and exists( + select + name + from + `tab{filters.budget_against}` + where + name = gl.{budget_against} + {cond} + ) + group by + gl.name + order by gl.fiscal_year + """, + (filters.from_fiscal_year, filters.to_fiscal_year, name), + as_dict=1, + ) + + cc_actual_details = {} + for d in ac_details: + cc_actual_details.setdefault(d.account, []).append(d) + + return cc_actual_details + + +def get_data_from_budget_map(budget_map, filters): data = [] - for dimension in dimensions: - dimension_items = cam_map.get(dimension) - if dimension_items: - data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, 0) - chart = get_chart_data(filters, columns, data) + fiscal_years = get_fiscal_years(filters) + group_months = filters["period"] != "Monthly" - return columns, data, None, chart + for dimension, accounts in budget_map.items(): + for account in accounts: + row = { + "budget_against": dimension, + "account": account, + } + total_budget = 0 + total_actual = 0 -def get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation): - for account, monthwise_data in dimension_items.items(): - row = [dimension, account] - totals = [0, 0, 0] - for year in get_fiscal_years(filters): - last_total = 0 - for relevant_months in period_month_ranges: - period_data = [0, 0, 0] - for month in relevant_months: - if monthwise_data.get(year[0]): - month_data = monthwise_data.get(year[0]).get(month, {}) - for i, fieldname in enumerate(["target", "actual", "variance"]): - value = flt(month_data.get(fieldname)) - period_data[i] += value - totals[i] += value + for fy in fiscal_years: + fy_name = fy[0] - period_data[0] += last_total + for from_date, to_date in get_period_date_ranges(filters["period"], fy_name): + months = get_months_between(from_date, to_date) - if DCC_allocation: - period_data[0] = period_data[0] * (DCC_allocation / 100) - period_data[1] = period_data[1] * (DCC_allocation / 100) + period_budget = 0 + period_actual = 0 - if filters.get("show_cumulative"): - last_total = period_data[0] - period_data[1] + for month in months: + b, a = get_budget_actual(budget_map, dimension, account, fy_name, month) + period_budget += b + period_actual += a - period_data[2] = period_data[0] - period_data[1] - row += period_data - totals[2] = totals[0] - totals[1] - if filters["period"] != "Yearly": - row += totals - data.append(row) + if filters["period"] == "Yearly": + budget_label = _("Budget") + " " + fy_name + actual_label = _("Actual") + " " + fy_name + variance_label = _("Variance") + " " + fy_name + else: + if group_months: + label_suffix = formatdate(from_date, "MMM") + "-" + formatdate(to_date, "MMM") + else: + label_suffix = formatdate(from_date, "MMM") + + budget_label = _("Budget") + f" ({label_suffix}) {fy_name}" + actual_label = _("Actual") + f" ({label_suffix}) {fy_name}" + variance_label = _("Variance") + f" ({label_suffix}) {fy_name}" + + row[frappe.scrub(budget_label)] = period_budget + row[frappe.scrub(actual_label)] = period_actual + row[frappe.scrub(variance_label)] = period_budget - period_actual + + total_budget += period_budget + total_actual += period_actual + + if filters["period"] != "Yearly": + row["total_budget"] = total_budget + row["total_actual"] = total_actual + row["total_variance"] = total_budget - total_actual + + data.append(row) return data +def get_months_between(from_date, to_date): + months = [] + current = from_date + + while current <= to_date: + months.append(formatdate(current, "MMMM")) + current = add_months(current, 1) + + return months + + +def get_budget_actual(budget_map, dim, acc, fy, month): + try: + data = budget_map[dim][acc][fy].get(month) + if not data: + return 0, 0 + return data.get("budget", 0), data.get("actual", 0) + except KeyError: + return 0, 0 + + +def get_fiscal_year_range_dates(from_fy, to_fy): + start_fy = frappe.get_doc("Fiscal Year", from_fy) + end_fy = frappe.get_doc("Fiscal Year", to_fy) + + return start_fy.year_start_date, end_fy.year_end_date + + def get_columns(filters): columns = [ { @@ -81,7 +262,7 @@ def get_columns(filters): }, { "label": _("Account"), - "fieldname": "Account", + "fieldname": "account", "fieldtype": "Link", "options": "Account", "width": 150, @@ -134,192 +315,6 @@ def get_columns(filters): return columns -def get_cost_centers(filters): - order_by = "" - if filters.get("budget_against") == "Cost Center": - order_by = "order by lft" - - if filters.get("budget_against") in ["Cost Center", "Project"]: - return frappe.db.sql_list( - """ - select - name - from - `tab{tab}` - where - company = %s - {order_by} - """.format(tab=filters.get("budget_against"), order_by=order_by), - filters.get("company"), - ) - else: - return frappe.db.sql_list( - """ - select - name - from - `tab{tab}` - """.format(tab=filters.get("budget_against")) - ) # nosec - - -# Get dimension & target details -def get_dimension_target_details(filters): - budget_against = frappe.scrub(filters.get("budget_against")) - cond = "" - if filters.get("budget_against_filter"): - cond += f""" and b.{budget_against} in (%s)""" % ", ".join( - ["%s"] * len(filters.get("budget_against_filter")) - ) - - return frappe.db.sql( - f""" - select - b.{budget_against} as budget_against, - b.monthly_distribution, - ba.account, - ba.budget_amount, - b.fiscal_year - from - `tabBudget` b, - `tabBudget Account` ba - where - b.name = ba.parent - and b.docstatus = 1 - and b.fiscal_year between %s and %s - and b.budget_against = %s - and b.company = %s - {cond} - order by - b.fiscal_year - """, - tuple( - [ - filters.from_fiscal_year, - filters.to_fiscal_year, - filters.budget_against, - filters.company, - ] - + (filters.get("budget_against_filter") or []) - ), - as_dict=True, - ) - - -# Get target distribution details of accounts of cost center -def get_target_distribution_details(filters): - target_details = {} - for d in frappe.db.sql( - """ - select - md.name, - mdp.month, - mdp.percentage_allocation - from - `tabMonthly Distribution Percentage` mdp, - `tabMonthly Distribution` md - where - mdp.parent = md.name - and md.fiscal_year between %s and %s - order by - md.fiscal_year - """, - (filters.from_fiscal_year, filters.to_fiscal_year), - as_dict=1, - ): - target_details.setdefault(d.name, {}).setdefault(d.month, flt(d.percentage_allocation)) - - return target_details - - -# Get actual details from gl entry -def get_actual_details(name, filters): - budget_against = frappe.scrub(filters.get("budget_against")) - cond = "" - - if filters.get("budget_against") == "Cost Center": - cc_lft, cc_rgt = frappe.db.get_value("Cost Center", name, ["lft", "rgt"]) - cond = f""" - and lft >= "{cc_lft}" - and rgt <= "{cc_rgt}" - """ - - ac_details = frappe.db.sql( - f""" - select - gl.account, - gl.debit, - gl.credit, - gl.fiscal_year, - MONTHNAME(gl.posting_date) as month_name, - b.{budget_against} as budget_against - from - `tabGL Entry` gl, - `tabBudget Account` ba, - `tabBudget` b - where - b.name = ba.parent - and b.docstatus = 1 - and ba.account=gl.account - and b.{budget_against} = gl.{budget_against} - and gl.fiscal_year between %s and %s - and gl.is_cancelled = 0 - and b.{budget_against} = %s - and exists( - select - name - from - `tab{filters.budget_against}` - where - name = gl.{budget_against} - {cond} - ) - group by - gl.name - order by gl.fiscal_year - """, - (filters.from_fiscal_year, filters.to_fiscal_year, name), - as_dict=1, - ) - - cc_actual_details = {} - for d in ac_details: - cc_actual_details.setdefault(d.account, []).append(d) - - return cc_actual_details - - -def get_dimension_account_month_map(filters): - dimension_target_details = get_dimension_target_details(filters) - tdd = get_target_distribution_details(filters) - - cam_map = {} - - for ccd in dimension_target_details: - actual_details = get_actual_details(ccd.budget_against, filters) - - for month_id in range(1, 13): - month = datetime.date(2013, month_id, 1).strftime("%B") - cam_map.setdefault(ccd.budget_against, {}).setdefault(ccd.account, {}).setdefault( - ccd.fiscal_year, {} - ).setdefault(month, frappe._dict({"target": 0.0, "actual": 0.0})) - - tav_dict = cam_map[ccd.budget_against][ccd.account][ccd.fiscal_year][month] - month_percentage = ( - tdd.get(ccd.monthly_distribution, {}).get(month, 0) - if ccd.monthly_distribution - else 100.0 / 12 - ) - - tav_dict.target = flt(ccd.budget_amount) * month_percentage / 100 - - for ad in actual_details.get(ccd.account, []): - if ad.month_name == month and ad.fiscal_year == ccd.fiscal_year: - tav_dict.actual += flt(ad.debit) - flt(ad.credit) - - return cam_map - - def get_fiscal_years(filters): fiscal_year = frappe.db.sql( """ @@ -334,52 +329,3 @@ def get_fiscal_years(filters): ) return fiscal_year - - -def get_chart_data(filters, columns, data): - if not data: - return None - - labels = [] - - fiscal_year = get_fiscal_years(filters) - group_months = False if filters["period"] == "Monthly" else True - - for year in fiscal_year: - for from_date, to_date in get_period_date_ranges(filters["period"], year[0]): - if filters["period"] == "Yearly": - labels.append(year[0]) - else: - if group_months: - label = ( - formatdate(from_date, format_string="MMM") - + "-" - + formatdate(to_date, format_string="MMM") - ) - labels.append(label) - else: - label = formatdate(from_date, format_string="MMM") - labels.append(label) - - no_of_columns = len(labels) - - budget_values, actual_values = [0] * no_of_columns, [0] * no_of_columns - for d in data: - values = d[2:] - index = 0 - - for i in range(no_of_columns): - budget_values[i] += values[index] - actual_values[i] += values[index + 1] - index += 3 - - return { - "data": { - "labels": labels, - "datasets": [ - {"name": _("Budget"), "chartType": "bar", "values": budget_values}, - {"name": _("Actual Expense"), "chartType": "bar", "values": actual_values}, - ], - }, - "type": "bar", - } From 8108fe4ca554dbdf7087a7186b888ce8b8877e89 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 30 Dec 2025 02:45:56 +0530 Subject: [PATCH 02/12] fix: correct query of fetching budget records --- .../report/budget_variance_report/budget_variance_report.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 7ed62baee45..65fa430bd5b 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -58,8 +58,7 @@ def fetch_budget_accounts(filters): b.company = %s AND b.docstatus = 1 AND b.budget_against = %s - AND %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year - AND %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year + AND (%s BETWEEN b.from_fiscal_year AND b.to_fiscal_year OR %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year) """, (filters.company, filters.budget_against, filters.from_fiscal_year, filters.to_fiscal_year), as_dict=True, @@ -86,7 +85,6 @@ def build_budget_map(budget_records, filters): "actual": 0, } for ad in actual_amt.get(budget.account, []): - print("AD RECORD", ad, "\n\n\n") if ad.month_name == month and ad.fiscal_year == fiscal_year: budget_map[budget.dimension][budget.account][fiscal_year][month]["actual"] += flt( ad.debit From e3fb7f4c4772b1313ad98513b954e0cf12057bcd Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 30 Dec 2025 15:11:02 +0530 Subject: [PATCH 03/12] fix: include budget with for multiple fiscal years --- .../budget_variance_report.js | 2 +- .../budget_variance_report.json | 49 ++++++++++--------- .../budget_variance_report.py | 18 +++++-- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js index 989dfca042d..c74450191aa 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.js +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.js @@ -6,7 +6,7 @@ frappe.query_reports["Budget Variance Report"] = { formatter: function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname && column.fieldname.startsWith("variance")) { + if (column.fieldname.includes(__("variance"))) { if (data[column.fieldname] < 0) { value = "" + value + ""; } else if (data[column.fieldname] > 0) { diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.json b/erpnext/accounts/report/budget_variance_report/budget_variance_report.json index 8e49bc532d3..20593612f84 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.json +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.json @@ -1,35 +1,40 @@ { - "add_total_row": 0, - "apply_user_permissions": 1, - "creation": "2013-06-18 12:56:36", - "disabled": 0, - "docstatus": 0, - "doctype": "Report", - "idx": 3, - "is_standard": "Yes", - "modified": "2017-02-24 20:19:06.964033", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Budget Variance Report", - "owner": "Administrator", - "ref_doctype": "Cost Center", - "report_name": "Budget Variance Report", - "report_type": "Script Report", + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2013-06-18 12:56:36", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 3, + "is_standard": "Yes", + "letter_head": null, + "modified": "2025-12-30 14:51:02.061226", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Budget Variance Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Cost Center", + "report_name": "Budget Variance Report", + "report_type": "Script Report", "roles": [ { "role": "Accounts Manager" - }, + }, { "role": "Auditor" - }, + }, { "role": "Accounts User" - }, + }, { "role": "Sales User" - }, + }, { "role": "Purchase User" } - ] -} \ No newline at end of file + ], + "timeout": 0 +} diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 65fa430bd5b..6854ffecc0f 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -41,11 +41,12 @@ def get_month_list(start_date, end_date): def fetch_budget_accounts(filters): budget_against_field = frappe.scrub(filters["budget_against"]) - budget_records = frappe.db.sql( + + return frappe.db.sql( f""" SELECT b.name, - b.account, + b.account, b.{budget_against_field} AS dimension, b.budget_amount, b.from_fiscal_year, @@ -58,12 +59,19 @@ def fetch_budget_accounts(filters): b.company = %s AND b.docstatus = 1 AND b.budget_against = %s - AND (%s BETWEEN b.from_fiscal_year AND b.to_fiscal_year OR %s BETWEEN b.from_fiscal_year AND b.to_fiscal_year) + AND ( + b.from_fiscal_year <= %s + AND b.to_fiscal_year >= %s + ) """, - (filters.company, filters.budget_against, filters.from_fiscal_year, filters.to_fiscal_year), + ( + filters.company, + filters.budget_against, + filters.to_fiscal_year, + filters.from_fiscal_year, + ), as_dict=True, ) - return budget_records def build_budget_map(budget_records, filters): From c57a43b3f4c7494453d0ddd3d1299fb74edd6d8d Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 30 Dec 2025 16:46:24 +0530 Subject: [PATCH 04/12] fix: distribute non-monthly budgets across months when creating budget map --- .../budget_variance_report.py | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 6854ffecc0f..2fc5a7b8181 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -81,26 +81,48 @@ def build_budget_map(budget_records, filters): actual_amt = get_actual_details(budget.dimension, filters) budget_map.setdefault(budget.dimension, {}) budget_map[budget.dimension].setdefault(budget.account, {}) + budget_distributions = get_budget_distributions(budget) for row in budget_distributions: - fiscal_year = get_fiscal_year(row.start_date)[0] - month = row.start_date.strftime("%B") - budget_map[budget.dimension][budget.account].setdefault(fiscal_year, {}) - budget_map[budget.dimension][budget.account][fiscal_year].setdefault(month, {}) - budget_map[budget.dimension][budget.account][fiscal_year][month] = { - "budget": row.amount, - "actual": 0, - } - for ad in actual_amt.get(budget.account, []): - if ad.month_name == month and ad.fiscal_year == fiscal_year: - budget_map[budget.dimension][budget.account][fiscal_year][month]["actual"] += flt( - ad.debit - ) - flt(ad.credit) + months = get_months_in_range(row.start_date, row.end_date) + monthly_budget = flt(row.amount) / len(months) + + for month_date in months: + fiscal_year = get_fiscal_year(month_date)[0] + month = month_date.strftime("%B") + + budget_map[budget.dimension][budget.account].setdefault(fiscal_year, {}) + budget_map[budget.dimension][budget.account][fiscal_year].setdefault( + month, + { + "budget": 0, + "actual": 0, + }, + ) + + budget_map[budget.dimension][budget.account][fiscal_year][month]["budget"] += monthly_budget + + for ad in actual_amt.get(budget.account, []): + if ad.month_name == month and ad.fiscal_year == fiscal_year: + budget_map[budget.dimension][budget.account][fiscal_year][month]["actual"] += flt( + ad.debit + ) - flt(ad.credit) return budget_map +def get_months_in_range(start_date, end_date): + months = [] + current = start_date + + while current <= end_date: + months.append(current) + current = add_months(current, 1) + + return months + + def get_budget_distributions(budget): return frappe.db.sql( """ From 24757465ce71418c912850b5a1b075e7c2b10b04 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 31 Dec 2025 11:25:06 +0530 Subject: [PATCH 05/12] fix: consider dimension filter while generating report --- .../budget_variance_report.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 2fc5a7b8181..fb87f7390f1 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -14,8 +14,12 @@ def execute(filters=None): filters = {} columns = get_columns(filters) + if filters.get("budget_against_filter"): + dimensions = filters.get("budget_against_filter") + else: + dimensions = get_cost_centers(filters) - budget_records = fetch_budget_accounts(filters) + budget_records = fetch_budget_accounts(filters, dimensions) budget_map = build_budget_map(budget_records, filters) data = get_data_from_budget_map(budget_map, filters) @@ -39,7 +43,7 @@ def get_month_list(start_date, end_date): return months -def fetch_budget_accounts(filters): +def fetch_budget_accounts(filters, dimensions): budget_against_field = frappe.scrub(filters["budget_against"]) return frappe.db.sql( @@ -59,6 +63,7 @@ def fetch_budget_accounts(filters): b.company = %s AND b.docstatus = 1 AND b.budget_against = %s + AND b.{budget_against_field} IN ({', '.join(['%s'] * len(dimensions))}) AND ( b.from_fiscal_year <= %s AND b.to_fiscal_year >= %s @@ -67,6 +72,7 @@ def fetch_budget_accounts(filters): ( filters.company, filters.budget_against, + *dimensions, filters.to_fiscal_year, filters.from_fiscal_year, ), @@ -357,3 +363,32 @@ def get_fiscal_years(filters): ) return fiscal_year + + +def get_cost_centers(filters): + order_by = "" + if filters.get("budget_against") == "Cost Center": + order_by = "order by lft" + + if filters.get("budget_against") in ["Cost Center", "Project"]: + return frappe.db.sql_list( + """ + select + name + from + `tab{tab}` + where + company = %s + {order_by} + """.format(tab=filters.get("budget_against"), order_by=order_by), + filters.get("company"), + ) + else: + return frappe.db.sql_list( + """ + select + name + from + `tab{tab}` + """.format(tab=filters.get("budget_against")) + ) # nosec From f56a673baaa82cbd3b17c2e674f4f896d1678100 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 31 Dec 2025 11:40:15 +0530 Subject: [PATCH 06/12] refactor: formatted code --- .../budget_variance_report.py | 71 +++++++------------ 1 file changed, 24 insertions(+), 47 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index fb87f7390f1..aa4cb5f34de 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -27,22 +27,6 @@ def execute(filters=None): return columns, data -def get_fiscal_year_dates(fiscal_year): - fy = frappe.get_doc("Fiscal Year", fiscal_year) - return fy.year_start_date, fy.year_end_date - - -def get_month_list(start_date, end_date): - months = [] - current = start_date - - while current <= end_date: - months.append(current.strftime("%Y-%m")) - current = add_months(current, 1) - - return months - - def fetch_budget_accounts(filters, dimensions): budget_against_field = frappe.scrub(filters["budget_against"]) @@ -118,30 +102,6 @@ def build_budget_map(budget_records, filters): return budget_map -def get_months_in_range(start_date, end_date): - months = [] - current = start_date - - while current <= end_date: - months.append(current) - current = add_months(current, 1) - - return months - - -def get_budget_distributions(budget): - return frappe.db.sql( - """ - SELECT start_date, end_date, amount, percent - FROM `tabBudget Distribution` - WHERE parent = %s - ORDER BY start_date ASC - """, - (budget.name,), - as_dict=True, - ) - - def get_actual_details(name, filters): budget_against = frappe.scrub(filters.get("budget_against")) cond = "" @@ -196,6 +156,30 @@ def get_actual_details(name, filters): return cc_actual_details +def get_budget_distributions(budget): + return frappe.db.sql( + """ + SELECT start_date, end_date, amount, percent + FROM `tabBudget Distribution` + WHERE parent = %s + ORDER BY start_date ASC + """, + (budget.name,), + as_dict=True, + ) + + +def get_months_in_range(start_date, end_date): + months = [] + current = start_date + + while current <= end_date: + months.append(current) + current = add_months(current, 1) + + return months + + def get_data_from_budget_map(budget_map, filters): data = [] @@ -278,13 +262,6 @@ def get_budget_actual(budget_map, dim, acc, fy, month): return 0, 0 -def get_fiscal_year_range_dates(from_fy, to_fy): - start_fy = frappe.get_doc("Fiscal Year", from_fy) - end_fy = frappe.get_doc("Fiscal Year", to_fy) - - return start_fy.year_start_date, end_fy.year_end_date - - def get_columns(filters): columns = [ { From f6a4f696a1f1605df7e560fc349da1c5d6e14cb9 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 5 Jan 2026 18:43:20 +0530 Subject: [PATCH 07/12] fix: Show Cumulative Amount based on checkbox in filter --- .../budget_variance_report.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index aa4cb5f34de..8db3f8b9ceb 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -183,6 +183,8 @@ def get_months_in_range(start_date, end_date): def get_data_from_budget_map(budget_map, filters): data = [] + show_cumulative = filters.get("show_cumulative") and filters.get("period") != "Yearly" + fiscal_years = get_fiscal_years(filters) group_months = filters["period"] != "Monthly" @@ -193,12 +195,15 @@ def get_data_from_budget_map(budget_map, filters): "account": account, } - total_budget = 0 - total_actual = 0 - for fy in fiscal_years: fy_name = fy[0] + running_budget = 0 + running_actual = 0 + + total_budget = 0 + total_actual = 0 + for from_date, to_date in get_period_date_ranges(filters["period"], fy_name): months = get_months_between(from_date, to_date) @@ -224,13 +229,19 @@ def get_data_from_budget_map(budget_map, filters): actual_label = _("Actual") + f" ({label_suffix}) {fy_name}" variance_label = _("Variance") + f" ({label_suffix}) {fy_name}" + total_budget += period_budget + total_actual += period_actual + + if show_cumulative: + running_budget += period_budget + running_actual += period_actual + period_budget = running_budget + period_actual = running_actual + row[frappe.scrub(budget_label)] = period_budget row[frappe.scrub(actual_label)] = period_actual row[frappe.scrub(variance_label)] = period_budget - period_actual - total_budget += period_budget - total_actual += period_actual - if filters["period"] != "Yearly": row["total_budget"] = total_budget row["total_actual"] = total_actual From 244319bf1d0e764e912897ed627342df81e7c170 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 5 Jan 2026 18:48:17 +0530 Subject: [PATCH 08/12] fix: show budget variance chart --- .../budget_variance_report.py | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 8db3f8b9ceb..a9684d08004 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -24,7 +24,9 @@ def execute(filters=None): data = get_data_from_budget_map(budget_map, filters) - return columns, data + chart_data = get_chart_data(filters, columns, data) + + return columns, data, None, chart_data def fetch_budget_accounts(filters, dimensions): @@ -380,3 +382,59 @@ def get_cost_centers(filters): `tab{tab}` """.format(tab=filters.get("budget_against")) ) # nosec + + +def get_chart_data(filters, columns, data): + if not data: + return None + + budget_fields = [] + actual_fields = [] + + for col in columns: + fieldname = col.get("fieldname") + if not fieldname: + continue + + if fieldname.startswith("budget_"): + budget_fields.append(fieldname) + elif fieldname.startswith("actual_"): + actual_fields.append(fieldname) + + if not budget_fields or not actual_fields: + return None + + labels = [ + col["label"].replace("Budget", "").strip() + for col in columns + if col.get("fieldname", "").startswith("budget_") + ] + + budget_values = [0] * len(budget_fields) + actual_values = [0] * len(actual_fields) + + for row in data: + for i, field in enumerate(budget_fields): + budget_values[i] += flt(row.get(field)) + + for i, field in enumerate(actual_fields): + actual_values[i] += flt(row.get(field)) + + return { + "data": { + "labels": labels, + "datasets": [ + { + "name": _("Budget"), + "chartType": "bar", + "values": budget_values, + }, + { + "name": _("Actual Expense"), + "chartType": "bar", + "values": actual_values, + }, + ], + }, + "type": "bar", + } From 53b13501a9a6d9b37f22637bdbc12989a28b8a87 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 6 Jan 2026 00:40:32 +0530 Subject: [PATCH 09/12] fix: get correct total budget data --- .../budget_variance_report/budget_variance_report.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index a9684d08004..f99f6601db5 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -197,15 +197,15 @@ def get_data_from_budget_map(budget_map, filters): "account": account, } + running_budget = 0 + running_actual = 0 + + total_budget = 0 + total_actual = 0 + for fy in fiscal_years: fy_name = fy[0] - running_budget = 0 - running_actual = 0 - - total_budget = 0 - total_actual = 0 - for from_date, to_date in get_period_date_ranges(filters["period"], fy_name): months = get_months_between(from_date, to_date) From f786c16a7d856fdea4267aa90c8163eb36ce09a7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 6 Jan 2026 01:05:10 +0530 Subject: [PATCH 10/12] refactor: better function and variable name --- .../budget_variance_report.py | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index f99f6601db5..c93b9220f93 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -17,14 +17,14 @@ def execute(filters=None): if filters.get("budget_against_filter"): dimensions = filters.get("budget_against_filter") else: - dimensions = get_cost_centers(filters) + dimensions = get_budget_dimensions(filters) budget_records = fetch_budget_accounts(filters, dimensions) budget_map = build_budget_map(budget_records, filters) - data = get_data_from_budget_map(budget_map, filters) + data = build_report_data(budget_map, filters) - chart_data = get_chart_data(filters, columns, data) + chart_data = build_comparison_chart_data(filters, columns, data) return columns, data, None, chart_data @@ -70,7 +70,7 @@ def build_budget_map(budget_records, filters): budget_map = {} for budget in budget_records: - actual_amt = get_actual_details(budget.dimension, filters) + actual_amt = get_actual_transactions(budget.dimension, filters) budget_map.setdefault(budget.dimension, {}) budget_map[budget.dimension].setdefault(budget.account, {}) @@ -104,18 +104,18 @@ def build_budget_map(budget_records, filters): return budget_map -def get_actual_details(name, filters): +def get_actual_transactions(dimension_name, filters): budget_against = frappe.scrub(filters.get("budget_against")) - cond = "" + cost_center_filter = "" - if filters.get("budget_against") == "Cost Center" and name: - cc_lft, cc_rgt = frappe.db.get_value("Cost Center", name, ["lft", "rgt"]) - cond = f""" + if filters.get("budget_against") == "Cost Center" and dimension_name: + cc_lft, cc_rgt = frappe.db.get_value("Cost Center", dimension_name, ["lft", "rgt"]) + cost_center_filter = f""" and lft >= "{cc_lft}" and rgt <= "{cc_rgt}" """ - ac_details = frappe.db.sql( + actual_transactions = frappe.db.sql( f""" select gl.account, @@ -141,21 +141,21 @@ def get_actual_details(name, filters): `tab{filters.budget_against}` where name = gl.{budget_against} - {cond} + {cost_center_filter} ) group by gl.name order by gl.fiscal_year """, - (filters.from_fiscal_year, filters.to_fiscal_year, name), + (filters.from_fiscal_year, filters.to_fiscal_year, dimension_name), as_dict=1, ) - cc_actual_details = {} - for d in ac_details: - cc_actual_details.setdefault(d.account, []).append(d) + actual_transactions_map = {} + for transaction in actual_transactions: + actual_transactions_map.setdefault(transaction.account, []).append(transaction) - return cc_actual_details + return actual_transactions_map def get_budget_distributions(budget): @@ -182,7 +182,7 @@ def get_months_in_range(start_date, end_date): return months -def get_data_from_budget_map(budget_map, filters): +def build_report_data(budget_map, filters): data = [] show_cumulative = filters.get("show_cumulative") and filters.get("period") != "Yearly" @@ -204,32 +204,34 @@ def get_data_from_budget_map(budget_map, filters): total_actual = 0 for fy in fiscal_years: - fy_name = fy[0] + fiscal_year = fy[0] - for from_date, to_date in get_period_date_ranges(filters["period"], fy_name): + for from_date, to_date in get_period_date_ranges(filters["period"], fiscal_year): months = get_months_between(from_date, to_date) period_budget = 0 period_actual = 0 for month in months: - b, a = get_budget_actual(budget_map, dimension, account, fy_name, month) - period_budget += b - period_actual += a + budget_amount, actual_amount = get_budget_and_actual_values( + budget_map, dimension, account, fiscal_year, month + ) + period_budget += budget_amount + period_actual += actual_amount if filters["period"] == "Yearly": - budget_label = _("Budget") + " " + fy_name - actual_label = _("Actual") + " " + fy_name - variance_label = _("Variance") + " " + fy_name + budget_label = _("Budget") + " " + fiscal_year + actual_label = _("Actual") + " " + fiscal_year + variance_label = _("Variance") + " " + fiscal_year else: if group_months: label_suffix = formatdate(from_date, "MMM") + "-" + formatdate(to_date, "MMM") else: label_suffix = formatdate(from_date, "MMM") - budget_label = _("Budget") + f" ({label_suffix}) {fy_name}" - actual_label = _("Actual") + f" ({label_suffix}) {fy_name}" - variance_label = _("Variance") + f" ({label_suffix}) {fy_name}" + budget_label = _("Budget") + f" ({label_suffix}) {fiscal_year}" + actual_label = _("Actual") + f" ({label_suffix}) {fiscal_year}" + variance_label = _("Variance") + f" ({label_suffix}) {fiscal_year}" total_budget += period_budget total_actual += period_actual @@ -265,9 +267,9 @@ def get_months_between(from_date, to_date): return months -def get_budget_actual(budget_map, dim, acc, fy, month): +def get_budget_and_actual_values(budget_map, dimension, account, fiscal_year, month): try: - data = budget_map[dim][acc][fy].get(month) + data = budget_map[dimension][account][fiscal_year].get(month) if not data: return 0, 0 return data.get("budget", 0), data.get("actual", 0) @@ -355,7 +357,7 @@ def get_fiscal_years(filters): return fiscal_year -def get_cost_centers(filters): +def get_budget_dimensions(filters): order_by = "" if filters.get("budget_against") == "Cost Center": order_by = "order by lft" @@ -384,7 +386,7 @@ def get_cost_centers(filters): ) # nosec -def get_chart_data(filters, columns, data): +def build_comparison_chart_data(filters, columns, data): if not data: return None From 7f6e509e20be3dc4a9a7eb4ef5fd6eed66d7901d Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 6 Jan 2026 01:25:56 +0530 Subject: [PATCH 11/12] refactor: more code cleanup --- .../budget_variance_report/budget_variance_report.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index c93b9220f93..9fb7b12c4a5 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -19,7 +19,7 @@ def execute(filters=None): else: dimensions = get_budget_dimensions(filters) - budget_records = fetch_budget_accounts(filters, dimensions) + budget_records = get_budget_records(filters, dimensions) budget_map = build_budget_map(budget_records, filters) data = build_report_data(budget_map, filters) @@ -29,7 +29,7 @@ def execute(filters=None): return columns, data, None, chart_data -def fetch_budget_accounts(filters, dimensions): +def get_budget_records(filters, dimensions): budget_against_field = frappe.scrub(filters["budget_against"]) return frappe.db.sql( @@ -67,6 +67,11 @@ def fetch_budget_accounts(filters, dimensions): def build_budget_map(budget_records, filters): + """ + Builds a nested dictionary structure aggregating budget and actual amounts. + + Structure: {dimension_name: {account_name: {fiscal_year: {month_name: {"budget": amount, "actual": amount}}}}} + """ budget_map = {} for budget in budget_records: From 07a69a073ddc7b2aa85cde7a5931fe5ce671c78a Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 6 Jan 2026 12:25:14 +0530 Subject: [PATCH 12/12] refactor: optimize budget variance report queries --- .../budget_variance_report.py | 111 ++++++++++-------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py index 9fb7b12c4a5..a53cdcc1b40 100644 --- a/erpnext/accounts/report/budget_variance_report/budget_variance_report.py +++ b/erpnext/accounts/report/budget_variance_report/budget_variance_report.py @@ -191,12 +191,10 @@ def build_report_data(budget_map, filters): data = [] show_cumulative = filters.get("show_cumulative") and filters.get("period") != "Yearly" - - fiscal_years = get_fiscal_years(filters) - group_months = filters["period"] != "Monthly" + periods = get_periods(filters) for dimension, accounts in budget_map.items(): - for account in accounts: + for account, fiscal_year_map in accounts.items(): row = { "budget_against": dimension, "account": account, @@ -204,52 +202,48 @@ def build_report_data(budget_map, filters): running_budget = 0 running_actual = 0 - total_budget = 0 total_actual = 0 - for fy in fiscal_years: - fiscal_year = fy[0] + for period in periods: + fiscal_year = period["fiscal_year"] + months = get_months_between(period["from_date"], period["to_date"]) - for from_date, to_date in get_period_date_ranges(filters["period"], fiscal_year): - months = get_months_between(from_date, to_date) + period_budget = 0 + period_actual = 0 - period_budget = 0 - period_actual = 0 + month_map = fiscal_year_map.get(fiscal_year, {}) - for month in months: - budget_amount, actual_amount = get_budget_and_actual_values( - budget_map, dimension, account, fiscal_year, month - ) - period_budget += budget_amount - period_actual += actual_amount + for month in months: + values = month_map.get(month) + if values: + period_budget += values.get("budget", 0) + period_actual += values.get("actual", 0) - if filters["period"] == "Yearly": - budget_label = _("Budget") + " " + fiscal_year - actual_label = _("Actual") + " " + fiscal_year - variance_label = _("Variance") + " " + fiscal_year - else: - if group_months: - label_suffix = formatdate(from_date, "MMM") + "-" + formatdate(to_date, "MMM") - else: - label_suffix = formatdate(from_date, "MMM") + if show_cumulative: + running_budget += period_budget + running_actual += period_actual + display_budget = running_budget + display_actual = running_actual + else: + display_budget = period_budget + display_actual = period_actual - budget_label = _("Budget") + f" ({label_suffix}) {fiscal_year}" - actual_label = _("Actual") + f" ({label_suffix}) {fiscal_year}" - variance_label = _("Variance") + f" ({label_suffix}) {fiscal_year}" + total_budget += period_budget + total_actual += period_actual - total_budget += period_budget - total_actual += period_actual + if filters["period"] == "Yearly": + budget_label = _("Budget") + " " + fiscal_year + actual_label = _("Actual") + " " + fiscal_year + variance_label = _("Variance") + " " + fiscal_year + else: + budget_label = _("Budget") + f" ({period['label_suffix']}) {fiscal_year}" + actual_label = _("Actual") + f" ({period['label_suffix']}) {fiscal_year}" + variance_label = _("Variance") + f" ({period['label_suffix']}) {fiscal_year}" - if show_cumulative: - running_budget += period_budget - running_actual += period_actual - period_budget = running_budget - period_actual = running_actual - - row[frappe.scrub(budget_label)] = period_budget - row[frappe.scrub(actual_label)] = period_actual - row[frappe.scrub(variance_label)] = period_budget - period_actual + row[frappe.scrub(budget_label)] = display_budget + row[frappe.scrub(actual_label)] = display_actual + row[frappe.scrub(variance_label)] = display_budget - display_actual if filters["period"] != "Yearly": row["total_budget"] = total_budget @@ -261,6 +255,33 @@ def build_report_data(budget_map, filters): return data +def get_periods(filters): + periods = [] + + group_months = filters["period"] != "Monthly" + + for (fiscal_year,) in get_fiscal_years(filters): + for from_date, to_date in get_period_date_ranges(filters["period"], fiscal_year): + if filters["period"] == "Yearly": + label_suffix = fiscal_year + else: + if group_months: + label_suffix = formatdate(from_date, "MMM") + "-" + formatdate(to_date, "MMM") + else: + label_suffix = formatdate(from_date, "MMM") + + periods.append( + { + "fiscal_year": fiscal_year, + "from_date": from_date, + "to_date": to_date, + "label_suffix": label_suffix, + } + ) + + return periods + + def get_months_between(from_date, to_date): months = [] current = from_date @@ -272,16 +293,6 @@ def get_months_between(from_date, to_date): return months -def get_budget_and_actual_values(budget_map, dimension, account, fiscal_year, month): - try: - data = budget_map[dimension][account][fiscal_year].get(month) - if not data: - return 0, 0 - return data.get("budget", 0), data.get("actual", 0) - except KeyError: - return 0, 0 - - def get_columns(filters): columns = [ {