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", - }