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 db42d23a839..a53cdcc1b40 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): @@ -19,57 +17,282 @@ 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) - period_month_ranges = get_period_month_ranges(filters["period"], filters["from_fiscal_year"]) - cam_map = get_dimension_account_month_map(filters) + budget_records = get_budget_records(filters, dimensions) + budget_map = build_budget_map(budget_records, filters) + data = build_report_data(budget_map, filters) + + chart_data = build_comparison_chart_data(filters, columns, data) + + return columns, data, None, chart_data + + +def get_budget_records(filters, dimensions): + budget_against_field = frappe.scrub(filters["budget_against"]) + + return 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 b.{budget_against_field} IN ({', '.join(['%s'] * len(dimensions))}) + AND ( + b.from_fiscal_year <= %s + AND b.to_fiscal_year >= %s + ) + """, + ( + filters.company, + filters.budget_against, + *dimensions, + filters.to_fiscal_year, + filters.from_fiscal_year, + ), + as_dict=True, + ) + + +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: + actual_amt = get_actual_transactions(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: + 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_actual_transactions(dimension_name, filters): + budget_against = frappe.scrub(filters.get("budget_against")) + cost_center_filter = "" + + 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}" + """ + + actual_transactions = 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} + {cost_center_filter} + ) + group by + gl.name + order by gl.fiscal_year + """, + (filters.from_fiscal_year, filters.to_fiscal_year, dimension_name), + as_dict=1, + ) + + actual_transactions_map = {} + for transaction in actual_transactions: + actual_transactions_map.setdefault(transaction.account, []).append(transaction) + + return actual_transactions_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_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 build_report_data(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) + show_cumulative = filters.get("show_cumulative") and filters.get("period") != "Yearly" + periods = get_periods(filters) - return columns, data, None, chart + for dimension, accounts in budget_map.items(): + for account, fiscal_year_map in accounts.items(): + row = { + "budget_against": dimension, + "account": account, + } + running_budget = 0 + running_actual = 0 + 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 period in periods: + fiscal_year = period["fiscal_year"] + months = get_months_between(period["from_date"], period["to_date"]) - period_data[0] += last_total + period_budget = 0 + period_actual = 0 - if DCC_allocation: - period_data[0] = period_data[0] * (DCC_allocation / 100) - period_data[1] = period_data[1] * (DCC_allocation / 100) + month_map = fiscal_year_map.get(fiscal_year, {}) - if filters.get("show_cumulative"): - last_total = period_data[0] - period_data[1] + for month in months: + values = month_map.get(month) + if values: + period_budget += values.get("budget", 0) + period_actual += values.get("actual", 0) - 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 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 + + 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}" + + 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 + row["total_actual"] = total_actual + row["total_variance"] = total_budget - total_actual + + data.append(row) 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 + + while current <= to_date: + months.append(formatdate(current, "MMMM")) + current = add_months(current, 1) + + return months + + def get_columns(filters): columns = [ { @@ -81,7 +304,7 @@ def get_columns(filters): }, { "label": _("Account"), - "fieldname": "Account", + "fieldname": "account", "fieldtype": "Link", "options": "Account", "width": 150, @@ -134,7 +357,23 @@ def get_columns(filters): return columns -def get_cost_centers(filters): +def get_fiscal_years(filters): + fiscal_year = frappe.db.sql( + """ + select + name + from + `tabFiscal Year` + where + name between %(from_fiscal_year)s and %(to_fiscal_year)s + """, + {"from_fiscal_year": filters["from_fiscal_year"], "to_fiscal_year": filters["to_fiscal_year"]}, + ) + + return fiscal_year + + +def get_budget_dimensions(filters): order_by = "" if filters.get("budget_against") == "Cost Center": order_by = "order by lft" @@ -163,222 +402,56 @@ def get_cost_centers(filters): ) # 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( - """ - select - name - from - `tabFiscal Year` - where - name between %(from_fiscal_year)s and %(to_fiscal_year)s - """, - {"from_fiscal_year": filters["from_fiscal_year"], "to_fiscal_year": filters["to_fiscal_year"]}, - ) - - return fiscal_year - - -def get_chart_data(filters, columns, data): +def build_comparison_chart_data(filters, columns, data): if not data: return None - labels = [] + budget_fields = [] + actual_fields = [] - fiscal_year = get_fiscal_years(filters) - group_months = False if filters["period"] == "Monthly" else True + for col in columns: + fieldname = col.get("fieldname") + if not fieldname: + continue - 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) + if fieldname.startswith("budget_"): + budget_fields.append(fieldname) + elif fieldname.startswith("actual_"): + actual_fields.append(fieldname) - no_of_columns = len(labels) + if not budget_fields or not actual_fields: + return None - budget_values, actual_values = [0] * no_of_columns, [0] * no_of_columns - for d in data: - values = d[2:] - index = 0 + labels = [ + col["label"].replace("Budget", "").strip() + for col in columns + if col.get("fieldname", "").startswith("budget_") + ] - for i in range(no_of_columns): - budget_values[i] += values[index] - actual_values[i] += values[index + 1] - index += 3 + 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}, + { + "name": _("Budget"), + "chartType": "bar", + "values": budget_values, + }, + { + "name": _("Actual Expense"), + "chartType": "bar", + "values": actual_values, + }, ], }, "type": "bar",