mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-03 05:28:27 +00:00
refactor: budget variance report
This commit is contained in:
@@ -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 = "<span style='color:red'>" + value + "</span>";
|
||||
} else if (data[column.fieldname] > 0) {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user