diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json
index 643f4f78d8f..849d4a5e927 100644
--- a/erpnext/accounts/doctype/budget/budget.json
+++ b/erpnext/accounts/doctype/budget/budget.json
@@ -13,14 +13,17 @@
"cost_center",
"project",
"account",
- "fiscal_year",
"column_break_3",
"amended_from",
+ "from_fiscal_year",
+ "to_fiscal_year",
"distribution_type",
"allocation_frequency",
- "budget_start_date",
- "budget_end_date",
"budget_amount",
+ "section_break_nwug",
+ "distribute_equally",
+ "section_break_fpdt",
+ "budget_distribution",
"section_break_6",
"applicable_on_material_request",
"action_if_annual_budget_exceeded_on_mr",
@@ -37,8 +40,6 @@
"applicable_on_cumulative_expense",
"action_if_annual_exceeded_on_cumulative_expense",
"action_if_accumulated_monthly_exceeded_on_cumulative_expense",
- "section_break_fpdt",
- "budget_distribution",
"section_break_kkan",
"revision_of"
],
@@ -51,6 +52,7 @@
"in_standard_filter": 1,
"label": "Budget Against",
"options": "\nCost Center\nProject",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -60,6 +62,7 @@
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -69,7 +72,8 @@
"in_global_search": 1,
"in_standard_filter": 1,
"label": "Cost Center",
- "options": "Cost Center"
+ "options": "Cost Center",
+ "read_only_depends_on": "eval: doc.revision_of"
},
{
"depends_on": "eval:doc.budget_against == 'Project'",
@@ -77,16 +81,8 @@
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "Project",
- "options": "Project"
- },
- {
- "fieldname": "fiscal_year",
- "fieldtype": "Link",
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Fiscal Year",
- "options": "Fiscal Year",
- "reqd": 1
+ "options": "Project",
+ "read_only_depends_on": "eval: doc.revision_of"
},
{
"fieldname": "column_break_3",
@@ -237,6 +233,7 @@
"fieldtype": "Link",
"label": "Account",
"options": "Account",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -245,18 +242,7 @@
"fieldtype": "Select",
"label": "Distribution Type",
"options": "Amount\nPercent",
- "reqd": 1
- },
- {
- "fieldname": "budget_start_date",
- "fieldtype": "Date",
- "label": "Budget Start Date",
- "reqd": 1
- },
- {
- "fieldname": "budget_end_date",
- "fieldtype": "Date",
- "label": "Budget End Date",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -264,14 +250,15 @@
"fieldname": "allocation_frequency",
"fieldtype": "Select",
"label": "Allocation Frequency",
- "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range",
+ "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
"fieldname": "budget_amount",
"fieldtype": "Currency",
"label": "Budget Amount",
- "options": "Company:company:default_currency",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -284,13 +271,40 @@
"label": "Revision Of",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "distribute_equally",
+ "fieldtype": "Check",
+ "label": "Distribute Equally"
+ },
+ {
+ "fieldname": "section_break_nwug",
+ "fieldtype": "Section Break",
+ "hide_border": 1
+ },
+ {
+ "fieldname": "from_fiscal_year",
+ "fieldtype": "Link",
+ "label": "From Fiscal Year",
+ "options": "Fiscal Year",
+ "read_only_depends_on": "eval: doc.revision_of",
+ "reqd": 1
+ },
+ {
+ "fieldname": "to_fiscal_year",
+ "fieldtype": "Link",
+ "label": "To Fiscal Year",
+ "options": "Fiscal Year",
+ "read_only_depends_on": "eval: doc.revision_of",
+ "reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2025-10-29 03:06:52.730795",
+ "modified": "2025-10-30 19:07:51.022844",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget",
diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py
index 58595e4f708..e0eaff8b4a3 100644
--- a/erpnext/accounts/doctype/budget/budget.py
+++ b/erpnext/accounts/doctype/budget/budget.py
@@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff
-from frappe.utils.data import get_first_day
+from frappe.utils.data import get_first_day, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -45,7 +45,7 @@ class Budget(Document):
action_if_annual_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_annual_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_annual_exceeded_on_cumulative_expense: DF.Literal["", "Stop", "Warn", "Ignore"]
- allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly", "Date Range"]
+ allocation_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
amended_from: DF.Link | None
applicable_on_booking_actual_expenses: DF.Check
applicable_on_cumulative_expense: DF.Check
@@ -54,15 +54,15 @@ class Budget(Document):
budget_against: DF.Literal["", "Cost Center", "Project"]
budget_amount: DF.Currency
budget_distribution: DF.Table[BudgetDistribution]
- budget_end_date: DF.Date
- budget_start_date: DF.Date
company: DF.Link
cost_center: DF.Link | None
+ distribute_equally: DF.Check
distribution_type: DF.Literal["Amount", "Percent"]
- fiscal_year: DF.Link
+ from_fiscal_year: DF.Link
naming_series: DF.Literal["BUDGET-.YYYY.-"]
project: DF.Link | None
revision_of: DF.Data | None
+ to_fiscal_year: DF.Link
# end: auto-generated types
def validate(self):
@@ -81,25 +81,38 @@ class Budget(Document):
if not account:
return
- existing_budget = frappe.db.get_all(
- "Budget",
- filters={
- "docstatus": ("<", 2),
- "company": self.company,
- budget_against_field: budget_against,
- "fiscal_year": self.fiscal_year,
- "account": account,
- "name": ("!=", self.name),
- },
- fields=["name", "account"],
+ from_start, _ = frappe.get_cached_value(
+ "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"]
+ )
+ _, to_end = frappe.get_cached_value(
+ "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"]
+ )
+
+ existing_budget = frappe.db.sql(
+ f"""
+ SELECT name, account
+ FROM `tabBudget`
+ WHERE
+ docstatus < 2
+ AND company = %s
+ AND {budget_against_field} = %s
+ AND account = %s
+ AND name != %s
+ AND (
+ (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
+ AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
+ )
+ """,
+ (self.company, budget_against, account, self.name, to_end, from_start),
+ as_dict=True,
)
if existing_budget:
d = existing_budget[0]
frappe.throw(
- _("Another Budget record '{0}' already exists against {1} '{2}' and account '{3}'").format(
- d.name, self.budget_against, budget_against, d.account
- ),
+ _(
+ "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years."
+ ).format(d.name, self.budget_against, budget_against, d.account),
DuplicateBudgetError,
)
@@ -153,75 +166,99 @@ class Budget(Document):
if self.revision_of:
return
- self.set("budget_distribution", [])
- if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency):
+ if not self.should_regenerate_budget_distribution():
return
- start = getdate(self.budget_start_date)
- end = getdate(self.budget_end_date)
- freq = self.allocation_frequency
+ self.set("budget_distribution", [])
- months = month_diff(end, start)
- if freq == "Monthly":
- total_periods = months
- elif freq == "Quarterly":
- total_periods = months // 3 + (1 if months % 3 else 0)
- elif freq == "Half-Yearly":
- total_periods = months // 6 + (1 if months % 6 else 0)
- else:
- total_periods = end.year - start.year + 1
+ self.set_budget_date_range()
+ periods = self.get_budget_periods()
+ total_periods = len(periods)
+ row_percent = 100 / total_periods if total_periods else 0
- if self.distribution_type == "Amount":
- per_row = flt(self.budget_amount / total_periods, 2)
- else:
- per_row = flt(100 / total_periods, 2)
-
- assigned = 0
- current = start
-
- while current <= end:
+ for start_date, end_date in periods:
row = self.append("budget_distribution", {})
+ row.start_date = start_date
+ row.end_date = end_date
+ self.add_allocated_amount(row, row_percent)
- if freq == "Monthly":
- row.start_date = get_first_day(current)
- row.end_date = get_last_day(current)
- current = add_months(current, 1)
- elif freq == "Quarterly":
- month = ((current.month - 1) // 3) * 3 + 1
- quarter_start = date(current.year, month, 1)
- quarter_end = get_last_day(add_months(quarter_start, 2))
- row.start_date = quarter_start
- row.end_date = min(quarter_end, end)
- current = add_months(quarter_start, 3)
- elif freq == "Half-Yearly":
- half = 1 if current.month <= 6 else 2
- half_start = date(current.year, 1, 1) if half == 1 else date(current.year, 7, 1)
- half_end = date(current.year, 6, 30) if half == 1 else date(current.year, 12, 31)
- row.start_date = half_start
- row.end_date = min(half_end, end)
- current = add_months(half_start, 6)
- else: # Yearly
- year_start = date(current.year, 1, 1)
- year_end = date(current.year, 12, 31)
- row.start_date = year_start
- row.end_date = min(year_end, end)
- current = date(current.year + 1, 1, 1)
+ def should_regenerate_budget_distribution(self):
+ """Check whether budget distribution should be recalculated."""
+ old_doc = self.get_doc_before_save() if not self.is_new() else None
- if self.distribution_type == "Amount":
- if len(self.budget_distribution) == total_periods:
- row.amount = flt(self.budget_amount - assigned)
+ if not self.budget_distribution:
+ return True
- else:
- row.amount = per_row
- assigned += per_row
- row.percent = flt(row.amount * 100 / self.budget_amount)
- else:
- if len(self.budget_distribution) == total_periods:
- row.percent = flt(100 - assigned)
- else:
- row.percent = per_row
- assigned += per_row
- row.amount = flt(row.percent * self.budget_amount / 100)
+ if old_doc:
+ changed_fields = [
+ "from_fiscal_year",
+ "to_fiscal_year",
+ "allocation_frequency",
+ "distribute_equally",
+ ]
+ for field in changed_fields:
+ if old_doc.get(field) != self.get(field):
+ return True
+
+ return bool(self.distribute_equally)
+
+ def set_budget_date_range(self):
+ """Set budget start and end dates based on selected fiscal years."""
+ from_fiscal_year = frappe.db.get_value(
+ "Fiscal Year", self.from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
+ )
+ to_fiscal_year = frappe.db.get_value(
+ "Fiscal Year", self.to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
+ )
+
+ self.budget_start_date = from_fiscal_year.year_start_date
+ self.budget_end_date = to_fiscal_year.year_end_date
+
+ def get_budget_periods(self):
+ """Return list of (start_date, end_date) tuples based on frequency."""
+ frequency = self.allocation_frequency
+ periods = []
+
+ start_date = getdate(self.budget_start_date)
+ end_date = getdate(self.budget_end_date)
+
+ while start_date <= end_date:
+ period_start = get_first_day(start_date)
+ period_end = self.get_period_end(period_start, frequency)
+ period_end = min(period_end, end_date)
+
+ periods.append((period_start, period_end))
+ start_date = add_months(period_start, self.get_month_increment(frequency))
+
+ return periods
+
+ def get_period_end(self, start_date, frequency):
+ """Return the correct end date for a given frequency."""
+ if frequency == "Monthly":
+ return get_last_day(start_date)
+ elif frequency == "Quarterly":
+ return get_last_day(add_months(start_date, 2))
+ elif frequency == "Half-Yearly":
+ return get_last_day(add_months(start_date, 5))
+ else: # Yearly
+ return get_last_day(add_months(start_date, 11))
+
+ def get_month_increment(self, frequency):
+ """Return how many months to move forward for the next period."""
+ return {
+ "Monthly": 1,
+ "Quarterly": 3,
+ "Half-Yearly": 6,
+ "Yearly": 12,
+ }.get(frequency, 1)
+
+ def add_allocated_amount(self, row, row_percent):
+ if not self.distribute_equally:
+ row.amount = 0
+ row.percent = 0
+ else:
+ row.amount = flt(self.budget_amount * row_percent / 100, 2)
+ row.percent = flt(row_percent, 3)
def validate_expense_against_budget(args, expense_amount=0):
@@ -229,17 +266,31 @@ def validate_expense_against_budget(args, expense_amount=0):
if not frappe.db.count("Budget", cache=True):
return
- if not args.fiscal_year:
- args.fiscal_year = get_fiscal_year(args.get("posting_date"), company=args.get("company"))[0]
+ posting_date = getdate(args.get("posting_date"))
+ posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0]
+ year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
+
+ budget_exists = frappe.db.sql(
+ """
+ select name
+ from `tabBudget`
+ where company = %s
+ and docstatus = 1
+ and (SELECT year_start_date FROM `tabFiscal Year` WHERE name = from_fiscal_year) <= %s
+ and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s
+ limit 1
+ """,
+ (args.company, year_end_date, year_start_date),
+ )
+
+ if not budget_exists:
+ return
if args.get("company"):
frappe.flags.exception_approver_role = frappe.get_cached_value(
"Company", args.get("company"), "exception_budget_approver_role"
)
- if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}):
- return
-
if not args.account:
args.account = args.get("expense_account")
@@ -284,22 +335,36 @@ def validate_expense_against_budget(args, expense_amount=0):
budget_records = frappe.db.sql(
f"""
- select
- b.name, b.{budget_against} as budget_against, b.budget_amount,
- ifnull(b.applicable_on_material_request, 0) as for_material_request,
- ifnull(applicable_on_purchase_order, 0) as for_purchase_order,
- ifnull(applicable_on_booking_actual_expenses,0) as for_actual_expenses,
- b.action_if_annual_budget_exceeded, b.action_if_accumulated_monthly_budget_exceeded,
- b.action_if_annual_budget_exceeded_on_mr, b.action_if_accumulated_monthly_budget_exceeded_on_mr,
- b.action_if_annual_budget_exceeded_on_po, b.action_if_accumulated_monthly_budget_exceeded_on_po
- from
+ SELECT
+ b.name,
+ b.{budget_against} AS budget_against,
+ b.budget_amount,
+ b.from_fiscal_year,
+ b.to_fiscal_year,
+ IFNULL(b.applicable_on_material_request, 0) AS for_material_request,
+ IFNULL(b.applicable_on_purchase_order, 0) AS for_purchase_order,
+ IFNULL(b.applicable_on_booking_actual_expenses, 0) AS for_actual_expenses,
+ b.action_if_annual_budget_exceeded,
+ b.action_if_accumulated_monthly_budget_exceeded,
+ b.action_if_annual_budget_exceeded_on_mr,
+ b.action_if_accumulated_monthly_budget_exceeded_on_mr,
+ b.action_if_annual_budget_exceeded_on_po,
+ b.action_if_accumulated_monthly_budget_exceeded_on_po
+ FROM
`tabBudget` b
- where
- b.fiscal_year=%s
- and b.account=%s and b.docstatus=1
+ WHERE
+ b.company = %s
+ AND b.docstatus = 1
+ AND (
+ %s BETWEEN
+ (SELECT year_start_date FROM `tabFiscal Year` WHERE name = b.from_fiscal_year)
+ AND
+ (SELECT year_end_date FROM `tabFiscal Year` WHERE name = b.to_fiscal_year)
+ )
+ AND b.account = %s
{condition}
- """,
- (args.fiscal_year, args.account),
+ """,
+ (args.company, args.posting_date, args.account),
as_dict=True,
) # nosec
@@ -313,6 +378,7 @@ def validate_budget_records(args, budget_records, expense_amount):
yearly_action, monthly_action = get_actions(args, budget)
args["for_material_request"] = budget.for_material_request
args["for_purchase_order"] = budget.for_purchase_order
+ args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year
if yearly_action in ("Stop", "Warn"):
compare_expense_with_budget(
@@ -384,7 +450,7 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_
def get_expense_breakup(args, currency, budget_against):
- msg = "
{{ _('Total Expenses booked through') }} - "
+ msg = "
{} - ".format(_("Total Expenses booked through"))
common_filters = frappe._dict(
{
@@ -394,23 +460,39 @@ def get_expense_breakup(args, currency, budget_against):
}
)
+ from_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date")
+ to_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date")
+ gl_filters = common_filters.copy()
+ gl_filters.update(
+ {
+ "from_date": from_date,
+ "to_date": to_date,
+ "is_cancelled": 0,
+ }
+ )
+
msg += (
"- "
+ frappe.utils.get_link_to_report(
"General Ledger",
label=_("Actual Expenses"),
- filters=common_filters.copy().update(
- {
- "from_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_start_date"),
- "to_date": frappe.get_cached_value("Fiscal Year", args.fiscal_year, "year_end_date"),
- "is_cancelled": 0,
- }
- ),
+ filters=gl_filters,
)
+ " - "
+ frappe.bold(fmt_money(args.actual_expense, currency=currency))
+ "
"
)
+ mr_filters = common_filters.copy()
+ mr_filters.update(
+ {
+ "status": [["!=", "Stopped"]],
+ "docstatus": 1,
+ "material_request_type": "Purchase",
+ "schedule_date": [["between", from_date, to_date]],
+ "item_code": args.item_code,
+ "per_ordered": [["<", 100]],
+ }
+ )
msg += (
"- "
@@ -419,22 +501,24 @@ def get_expense_breakup(args, currency, budget_against):
label=_("Material Requests"),
report_type="Report Builder",
doctype="Material Request",
- filters=common_filters.copy().update(
- {
- "status": [["!=", "Stopped"]],
- "docstatus": 1,
- "material_request_type": "Purchase",
- "schedule_date": [["fiscal year", "2023-2024"]],
- "item_code": args.item_code,
- "per_ordered": [["<", 100]],
- }
- ),
+ filters=mr_filters,
)
+ " - "
+ frappe.bold(fmt_money(args.requested_amount, currency=currency))
+ "
"
)
+ po_filters = common_filters.copy()
+ po_filters.update(
+ {
+ "status": [["!=", "Closed"]],
+ "docstatus": 1,
+ "transaction_date": [["between", from_date, to_date]],
+ "item_code": args.item_code,
+ "per_billed": [["<", 100]],
+ }
+ )
+
msg += (
"- "
+ frappe.utils.get_link_to_report(
@@ -442,15 +526,7 @@ def get_expense_breakup(args, currency, budget_against):
label=_("Unbilled Orders"),
report_type="Report Builder",
doctype="Purchase Order",
- filters=common_filters.copy().update(
- {
- "status": [["!=", "Closed"]],
- "docstatus": 1,
- "transaction_date": [["fiscal year", "2023-2024"]],
- "item_code": args.item_code,
- "per_billed": [["<", 100]],
- }
- ),
+ filters=po_filters,
)
+ " - "
+ frappe.bold(fmt_money(args.ordered_amount, currency=currency))
@@ -508,20 +584,18 @@ def get_ordered_amount(args):
def get_other_condition(args, for_doc):
- condition = "expense_account = '%s'" % (args.expense_account)
+ condition = f"expense_account = '{args.expense_account}'"
budget_against_field = args.get("budget_against_field")
if budget_against_field and args.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'"
- if args.get("fiscal_year"):
- date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
- start_date, end_date = frappe.get_cached_value(
- "Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"]
- )
+ date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
- condition += f""" and parent.{date_field}
- between '{start_date}' and '{end_date}' """
+ start_date = frappe.get_cached_value("Fiscal Year", args.from_fiscal_year, "year_start_date")
+ end_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date")
+
+ condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
return condition
@@ -533,36 +607,48 @@ def get_actual_expense(args):
budget_against_field = args.get("budget_against_field")
condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else ""
+ from_start, _ = frappe.get_cached_value(
+ "Fiscal Year", args.from_fiscal_year, ["year_start_date", "year_end_date"]
+ )
+ _, to_end = frappe.get_cached_value(
+ "Fiscal Year", args.to_fiscal_year, ["year_start_date", "year_end_date"]
+ )
+
+ date_condition = f"and gle.posting_date between '{from_start}' and '{to_end}'"
+
if args.is_tree:
lft_rgt = frappe.db.get_value(
args.budget_against_doctype, args.get(budget_against_field), ["lft", "rgt"], as_dict=1
)
-
args.update(lft_rgt)
- condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}`
- where lft>=%(lft)s and rgt<=%(rgt)s
- and name=gle.{budget_against_field})"""
+ condition2 = f"""
+ and exists(
+ select name from `tab{args.budget_against_doctype}`
+ where lft >= %(lft)s and rgt <= %(rgt)s
+ and name = gle.{budget_against_field}
+ )
+ """
else:
- condition2 = f"""and exists(select name from `tab{args.budget_against_doctype}`
- where name=gle.{budget_against_field} and
- gle.{budget_against_field} = %({budget_against_field})s)"""
+ condition2 = f"""
+ and gle.{budget_against_field} = %({budget_against_field})s
+ """
amount = flt(
frappe.db.sql(
f"""
- select sum(gle.debit) - sum(gle.credit)
- from `tabGL Entry` gle
- where
- is_cancelled = 0
- and gle.account=%(account)s
- {condition1}
- and gle.fiscal_year=%(fiscal_year)s
- and gle.company=%(company)s
- and gle.docstatus=1
- {condition2}
- """,
- (args),
+ select sum(gle.debit) - sum(gle.credit)
+ from `tabGL Entry` gle
+ where
+ is_cancelled = 0
+ and gle.account = %(account)s
+ {condition1}
+ {date_condition}
+ and gle.company = %(company)s
+ and gle.docstatus = 1
+ {condition2}
+ """,
+ args,
)[0][0]
) # nosec
@@ -632,6 +718,16 @@ def get_expense_cost_center(doctype, args):
)
+def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
+ from_year = frappe.get_cached_value(
+ "Fiscal Year", from_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
+ )
+ to_year = frappe.get_cached_value(
+ "Fiscal Year", to_fiscal_year, ["year_start_date", "year_end_date"], as_dict=True
+ )
+ return from_year.year_start_date, to_year.year_end_date
+
+
@frappe.whitelist()
def revise_budget(budget_name):
old_budget = frappe.get_doc("Budget", budget_name)
@@ -643,7 +739,6 @@ def revise_budget(budget_name):
new_budget = frappe.copy_doc(old_budget)
new_budget.docstatus = 0
new_budget.revision_of = old_budget.name
- new_budget.posting_date = frappe.utils.nowdate()
new_budget.insert()
return new_budget.name
diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py
index e8402811950..d2723bc0db0 100644
--- a/erpnext/accounts/doctype/budget/test_budget.py
+++ b/erpnext/accounts/doctype/budget/test_budget.py
@@ -3,7 +3,8 @@
import unittest
import frappe
-from frappe.utils import add_days, getdate, now_datetime, nowdate
+from frappe.client import submit
+from frappe.utils import add_days, flt, get_first_day, get_last_day, getdate, now_datetime, nowdate
from erpnext.accounts.doctype.budget.budget import (
BudgetError,
@@ -34,7 +35,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_crossed_ignore(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center")
+ budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -55,7 +56,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_crossed_stop1(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center")
+ budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
@@ -79,7 +80,7 @@ class TestBudget(ERPNextTestSuite):
def test_exception_approver_role(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center")
+ budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
@@ -111,11 +112,11 @@ class TestBudget(ERPNextTestSuite):
applicable_on_purchase_order=1,
action_if_accumulated_monthly_budget_exceeded_on_mr="Stop",
budget_against="Cost Center",
+ do_not_save=False,
+ subimit_budget=True,
)
- fiscal_year = get_fiscal_year(nowdate())[0]
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
- frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year)
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
@@ -156,11 +157,11 @@ class TestBudget(ERPNextTestSuite):
applicable_on_purchase_order=1,
action_if_accumulated_monthly_budget_exceeded_on_po="Stop",
budget_against="Cost Center",
+ do_not_save=False,
+ submit_budget=True,
)
- fiscal_year = get_fiscal_year(nowdate())[0]
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
- frappe.db.set_value("Budget", budget.name, "fiscal_year", fiscal_year)
accumulated_limit = get_accumulated_monthly_budget(
budget.name,
@@ -181,7 +182,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_crossed_stop2(self):
set_total_expense_zero(nowdate(), "project")
- budget = make_budget(budget_against="Project")
+ budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
@@ -207,7 +208,7 @@ class TestBudget(ERPNextTestSuite):
def test_yearly_budget_crossed_stop1(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center")
+ budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -224,7 +225,7 @@ class TestBudget(ERPNextTestSuite):
def test_yearly_budget_crossed_stop2(self):
set_total_expense_zero(nowdate(), "project")
- budget = make_budget(budget_against="Project")
+ budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
project = frappe.get_value("Project", {"project_name": "_Test Project"})
@@ -244,7 +245,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_on_cancellation1(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center")
+ budget = make_budget(budget_against="Cost Center", do_not_save=False, submit_budget=True)
month = now_datetime().month
if month > 9:
month = 9
@@ -273,7 +274,7 @@ class TestBudget(ERPNextTestSuite):
def test_monthly_budget_on_cancellation2(self):
set_total_expense_zero(nowdate(), "project")
- budget = make_budget(budget_against="Project")
+ budget = make_budget(budget_against="Project", do_not_save=False, submit_budget=True)
month = now_datetime().month
if month > 9:
month = 9
@@ -305,7 +306,12 @@ class TestBudget(ERPNextTestSuite):
set_total_expense_zero(nowdate(), "cost_center")
set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center 2 - _TC")
- budget = make_budget(budget_against="Cost Center", cost_center="_Test Company - _TC")
+ budget = make_budget(
+ budget_against="Cost Center",
+ cost_center="_Test Company - _TC",
+ do_not_save=False,
+ submit_budget=True,
+ )
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(
@@ -339,7 +345,9 @@ class TestBudget(ERPNextTestSuite):
}
).insert(ignore_permissions=True)
- budget = make_budget(budget_against="Cost Center", cost_center=cost_center)
+ budget = make_budget(
+ budget_against="Cost Center", cost_center=cost_center, do_not_save=False, submit_budget=True
+ )
frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop")
accumulated_limit = get_accumulated_monthly_budget(
@@ -381,7 +389,12 @@ class TestBudget(ERPNextTestSuite):
{"Sub Budget Cost Center 1 - _TC": 60, "Sub Budget Cost Center 2 - _TC": 40},
)
- make_budget(budget_against="Cost Center", cost_center="Main Budget Cost Center 1 - _TC")
+ make_budget(
+ budget_against="Cost Center",
+ cost_center="Main Budget Cost Center 1 - _TC",
+ do_not_save=False,
+ submit_budget=True,
+ )
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -396,7 +409,12 @@ class TestBudget(ERPNextTestSuite):
def test_action_for_cumulative_limit(self):
set_total_expense_zero(nowdate(), "cost_center")
- budget = make_budget(budget_against="Cost Center", applicable_on_cumulative_expense=True)
+ budget = make_budget(
+ budget_against="Cost Center",
+ applicable_on_cumulative_expense=True,
+ do_not_save=False,
+ submit_budget=True,
+ )
accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate())
@@ -417,8 +435,6 @@ class TestBudget(ERPNextTestSuite):
)
po.set_missing_values()
- print(">>>>>>>>>>>>>>>>>>>>>>>>")
-
self.assertRaises(BudgetError, po.submit)
frappe.db.set_value(
@@ -434,7 +450,6 @@ class TestBudget(ERPNextTestSuite):
def test_distribution_date_validation(self):
budget = frappe.new_doc("Budget")
budget.company = self.company
- budget.fiscal_year = self.fiscal_year
budget.budget_against = "Cost Center"
budget.cost_center = self.cost_center
budget.account = self.account
@@ -456,9 +471,14 @@ class TestBudget(ERPNextTestSuite):
budget.save()
def test_total_distribution_equals_budget(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ applicable_on_cumulative_expense=True,
+ do_not_save=False,
+ submit_budget=True,
+ )
budget = frappe.new_doc("Budget")
budget.company = self.company
- budget.fiscal_year = self.fiscal_year
budget.budget_against = "Cost Center"
budget.cost_center = self.cost_center
budget.account = ("_Test Account Cost for Goods Sold - _TC",)
@@ -488,14 +508,18 @@ class TestBudget(ERPNextTestSuite):
budget.save()
def test_evenly_distribute_budget(self):
- budget = make_budget(budget_against="Cost Center", budget_amount=120000)
+ budget = make_budget(
+ budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
+ )
total = sum([d.amount for d in budget.budget_distribution])
- self.assertEqual(total, 120000)
+ self.assertEqual(flt(total), 120000)
self.assertTrue(all(d.amount == 10000 for d in budget.budget_distribution))
def test_create_revised_budget(self):
- budget = make_budget(budget_against="Cost Center", budget_amount=120000)
+ budget = make_budget(
+ budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
+ )
revised_name = revise_budget(budget.name)
@@ -508,7 +532,9 @@ class TestBudget(ERPNextTestSuite):
self.assertEqual(old_budget.docstatus, 2)
def test_revision_preserves_distribution(self):
- budget = make_budget(budget_against="Cost Center", budget_amount=120000)
+ budget = make_budget(
+ budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
+ )
revised_name = revise_budget(budget.name)
revised_budget = frappe.get_doc("Budget", revised_name)
@@ -518,6 +544,32 @@ class TestBudget(ERPNextTestSuite):
total = sum(row.amount for row in revised_budget.budget_distribution)
self.assertEqual(total, revised_budget.budget_amount)
+ def test_manual_budget_amount_total(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ distribute_equally=0,
+ budget_amount=30000,
+ budget_start_date="2025-04-01",
+ budget_end_date="2025-06-30",
+ do_not_save=True,
+ submit_budget=False,
+ )
+
+ budget.budget_distribution = []
+
+ for row in [
+ {"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000},
+ {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000},
+ {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000},
+ ]:
+ budget.append("budget_distribution", row)
+
+ budget.save()
+
+ total_child_amount = sum(row.amount for row in budget.budget_distribution)
+
+ self.assertEqual(total_child_amount, budget.budget_amount)
+
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":
@@ -533,7 +585,8 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again
"cost_center": "_Test Cost Center - _TC",
"monthly_end_date": posting_date,
"company": "_Test Company",
- "fiscal_year": fiscal_year,
+ "from_fiscal_year": fiscal_year,
+ "to_fiscal_year": fiscal_year,
"budget_against_field": budget_against_field,
}
)
@@ -605,7 +658,8 @@ def make_budget(**args):
else:
budget.cost_center = cost_center or "_Test Cost Center - _TC"
- budget.fiscal_year = fiscal_year
+ budget.from_fiscal_year = fiscal_year
+ budget.to_fiscal_year = fiscal_year
budget.company = "_Test Company"
budget.account = "_Test Account Cost for Goods Sold - _TC"
budget.budget_amount = args.budget_amount or 200000
@@ -614,10 +668,9 @@ def make_budget(**args):
budget.action_if_accumulated_monthly_budget_exceeded = "Ignore"
budget.budget_against = budget_against
- budget.budget_start_date = "2025-04-01"
- budget.budget_end_date = "2026-03-31"
budget.allocation_frequency = "Monthly"
budget.distribution_type = "Amount"
+ budget.distribute_equally = args.get("distribute_equally", 1)
if args.applicable_on_material_request:
budget.applicable_on_material_request = 1
@@ -642,7 +695,13 @@ def make_budget(**args):
args.action_if_accumulated_monthly_exceeded_on_cumulative_expense or "Warn"
)
- budget.insert()
- budget.submit()
+ if not args.do_not_save:
+ try:
+ budget.insert(ignore_if_duplicate=True)
+ except frappe.DuplicateEntryError:
+ pass
+
+ if args.submit_budget:
+ budget.submit()
return budget
diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
index 7602956e0c6..1a367010c98 100644
--- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
+++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
@@ -17,13 +17,15 @@
"fieldtype": "Date",
"in_list_view": 1,
"label": "Start Date",
+ "read_only": 1,
"search_index": 1
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"in_list_view": 1,
- "label": "End Date"
+ "label": "End Date",
+ "read_only": 1
},
{
"fieldname": "amount",
@@ -44,7 +46,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2025-10-15 16:53:23.462653",
+ "modified": "2025-10-30 12:35:31.310931",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget Distribution",