diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js
index d3931dec3db..3ac7b8fe8f8 100644
--- a/erpnext/accounts/doctype/budget/budget.js
+++ b/erpnext/accounts/doctype/budget/budget.js
@@ -4,16 +4,6 @@ frappe.provide("erpnext.accounts.dimensions");
frappe.ui.form.on("Budget", {
onload: function (frm) {
- frm.set_query("account", "accounts", function () {
- return {
- filters: {
- company: frm.doc.company,
- report_type: "Profit and Loss",
- is_group: 0,
- },
- };
- });
-
frm.set_query("monthly_distribution", function () {
return {
filters: {
@@ -30,8 +20,28 @@ frappe.ui.form.on("Budget", {
});
},
- refresh: function (frm) {
+ refresh: async function (frm) {
frm.trigger("toggle_reqd_fields");
+
+ if (!frm.doc.__islocal && frm.doc.docstatus == 1) {
+ let exception_role = await frappe.db.get_value(
+ "Company",
+ frm.doc.company,
+ "exception_budget_approver_role"
+ );
+
+ const role = exception_role.message.exception_budget_approver_role;
+
+ if (role && frappe.user.has_role(role)) {
+ frm.add_custom_button(
+ __("Revise Budget"),
+ function () {
+ frm.events.revise_budget_action(frm);
+ },
+ __("Actions")
+ );
+ }
+ }
},
budget_against: function (frm) {
@@ -39,6 +49,15 @@ frappe.ui.form.on("Budget", {
frm.trigger("toggle_reqd_fields");
},
+ budget_amount(frm) {
+ if (frm.doc.budget_distribution?.length) {
+ frm.doc.budget_distribution.forEach((row) => {
+ row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
+ });
+ frm.refresh_field("budget_distribution");
+ }
+ },
+
set_null_value: function (frm) {
if (frm.doc.budget_against == "Cost Center") {
frm.set_value("project", null);
@@ -51,4 +70,44 @@ frappe.ui.form.on("Budget", {
frm.toggle_reqd("cost_center", frm.doc.budget_against == "Cost Center");
frm.toggle_reqd("project", frm.doc.budget_against == "Project");
},
+
+ revise_budget_action: function (frm) {
+ frappe.confirm(
+ __(
+ "Are you sure you want to revise this budget? The current budget will be cancelled and a new draft will be created."
+ ),
+ function () {
+ frappe.call({
+ method: "erpnext.accounts.doctype.budget.budget.revise_budget",
+ args: { budget_name: frm.doc.name },
+ callback: function (r) {
+ if (r.message) {
+ frappe.msgprint(__("New revised budget created successfully"));
+ frappe.set_route("Form", "Budget", r.message);
+ }
+ },
+ });
+ },
+ function () {
+ frappe.msgprint(__("Revision cancelled"));
+ }
+ );
+ },
+});
+
+frappe.ui.form.on("Budget Distribution", {
+ amount(frm, cdt, cdn) {
+ let row = frappe.get_doc(cdt, cdn);
+ if (frm.doc.budget_amount) {
+ row.percent = flt((row.amount / frm.doc.budget_amount) * 100, 2);
+ frm.refresh_field("budget_distribution");
+ }
+ },
+ percent(frm, cdt, cdn) {
+ let row = frappe.get_doc(cdt, cdn);
+ if (frm.doc.budget_amount) {
+ row.amount = flt((row.percent / 100) * frm.doc.budget_amount, 2);
+ frm.refresh_field("budget_distribution");
+ }
+ },
});
diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json
index fcd78691a03..8476a2831f0 100644
--- a/erpnext/accounts/doctype/budget/budget.json
+++ b/erpnext/accounts/doctype/budget/budget.json
@@ -12,10 +12,19 @@
"company",
"cost_center",
"project",
- "fiscal_year",
+ "account",
"column_break_3",
- "monthly_distribution",
"amended_from",
+ "from_fiscal_year",
+ "to_fiscal_year",
+ "budget_start_date",
+ "budget_end_date",
+ "distribution_frequency",
+ "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",
@@ -32,8 +41,8 @@
"applicable_on_cumulative_expense",
"action_if_annual_exceeded_on_cumulative_expense",
"action_if_accumulated_monthly_exceeded_on_cumulative_expense",
- "section_break_21",
- "accounts"
+ "section_break_kkan",
+ "revision_of"
],
"fields": [
{
@@ -44,6 +53,7 @@
"in_standard_filter": 1,
"label": "Budget Against",
"options": "\nCost Center\nProject",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -53,6 +63,7 @@
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
+ "read_only_depends_on": "eval: doc.revision_of",
"reqd": 1
},
{
@@ -62,7 +73,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'",
@@ -70,28 +82,13 @@
"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",
"fieldtype": "Column Break"
},
- {
- "depends_on": "eval:in_list([\"Stop\", \"Warn\"], doc.action_if_accumulated_monthly_budget_exceeded_on_po || doc.action_if_accumulated_monthly_budget_exceeded_on_mr || doc.action_if_accumulated_monthly_budget_exceeded_on_actual)",
- "fieldname": "monthly_distribution",
- "fieldtype": "Link",
- "label": "Monthly Distribution",
- "options": "Monthly Distribution"
- },
{
"fieldname": "amended_from",
"fieldtype": "Link",
@@ -187,22 +184,12 @@
"options": "\nStop\nWarn\nIgnore"
},
{
- "fieldname": "section_break_21",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "accounts",
- "fieldtype": "Table",
- "label": "Budget Accounts",
- "options": "Budget Account",
- "reqd": 1
- },
- {
+ "default": "BUDGET-.########",
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"no_copy": 1,
- "options": "BUDGET-.YYYY.-",
+ "options": "BUDGET-.########",
"print_hide": 1,
"reqd": 1,
"set_only_once": 1
@@ -232,13 +219,97 @@
"fieldtype": "Select",
"label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense",
"options": "\nStop\nWarn\nIgnore"
+ },
+ {
+ "fieldname": "section_break_fpdt",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "budget_distribution",
+ "fieldtype": "Table",
+ "label": "Budget Distribution",
+ "options": "Budget Distribution"
+ },
+ {
+ "fieldname": "account",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Account",
+ "options": "Account",
+ "read_only_depends_on": "eval: doc.revision_of",
+ "reqd": 1
+ },
+ {
+ "fieldname": "budget_amount",
+ "fieldtype": "Currency",
+ "label": "Budget Amount",
+ "reqd": 1
+ },
+ {
+ "fieldname": "section_break_kkan",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "revision_of",
+ "fieldtype": "Data",
+ "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
+ },
+ {
+ "fieldname": "budget_start_date",
+ "fieldtype": "Date",
+ "hidden": 1,
+ "label": "Budget Start Date"
+ },
+ {
+ "fieldname": "budget_end_date",
+ "fieldtype": "Date",
+ "hidden": 1,
+ "label": "Budget End Date"
+ },
+ {
+ "default": "Monthly",
+ "fieldname": "distribution_frequency",
+ "fieldtype": "Select",
+ "label": "Distribution Frequency",
+ "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly",
+ "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-06-16 15:57:13.114981",
+ "modified": "2025-11-19 17:00:00.648224",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budget",
diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py
index a55c189f783..d798da5b589 100644
--- a/erpnext/accounts/doctype/budget/budget.py
+++ b/erpnext/accounts/doctype/budget/budget.py
@@ -2,10 +2,14 @@
# For license information, please see license.txt
+from datetime import date
+
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate
+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, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
@@ -30,9 +34,9 @@ class Budget(Document):
if TYPE_CHECKING:
from frappe.types import DF
- from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount
+ from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution
- accounts: DF.Table[BudgetAccount]
+ account: DF.Link
action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"]
action_if_accumulated_monthly_budget_exceeded_on_po: DF.Literal["", "Stop", "Warn", "Ignore"]
@@ -47,73 +51,117 @@ class Budget(Document):
applicable_on_material_request: DF.Check
applicable_on_purchase_order: DF.Check
budget_against: DF.Literal["", "Cost Center", "Project"]
+ budget_amount: DF.Currency
+ budget_distribution: DF.Table[BudgetDistribution]
+ budget_end_date: DF.Date | None
+ budget_start_date: DF.Date | None
company: DF.Link
cost_center: DF.Link | None
- fiscal_year: DF.Link
- monthly_distribution: DF.Link | None
- naming_series: DF.Literal["BUDGET-.YYYY.-"]
+ distribute_equally: DF.Check
+ distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
+ from_fiscal_year: DF.Link
+ naming_series: DF.Literal["BUDGET-.########"]
project: DF.Link | None
+ revision_of: DF.Data | None
+ to_fiscal_year: DF.Link
# end: auto-generated types
def validate(self):
if not self.get(frappe.scrub(self.budget_against)):
frappe.throw(_("{0} is mandatory").format(self.budget_against))
+ self.validate_budget_amount()
+ self.validate_fiscal_year()
+ self.set_fiscal_year_dates()
self.validate_duplicate()
- self.validate_accounts()
+ self.validate_account()
self.set_null_value()
self.validate_applicable_for()
+ self.validate_existing_expenses()
+
+ def validate_budget_amount(self):
+ if self.budget_amount <= 0:
+ frappe.throw(_("Budget Amount can not be {0}.").format(self.budget_amount))
+
+ def validate_fiscal_year(self):
+ if self.from_fiscal_year:
+ self.validate_fiscal_year_company(self.from_fiscal_year, self.company)
+ if self.to_fiscal_year:
+ self.validate_fiscal_year_company(self.to_fiscal_year, self.company)
+
+ def validate_fiscal_year_company(self, fiscal_year, company):
+ linked_companies = frappe.get_all(
+ "Fiscal Year Company", filters={"parent": fiscal_year}, pluck="company"
+ )
+ if linked_companies and company not in linked_companies:
+ frappe.throw(_("Fiscal Year {0} is not available for Company {1}.").format(fiscal_year, company))
+
+ def set_fiscal_year_dates(self):
+ if self.from_fiscal_year:
+ self.budget_start_date = frappe.get_cached_value(
+ "Fiscal Year", self.from_fiscal_year, "year_start_date"
+ )
+ if self.to_fiscal_year:
+ self.budget_end_date = frappe.get_cached_value(
+ "Fiscal Year", self.to_fiscal_year, "year_end_date"
+ )
+
+ if self.budget_start_date > self.budget_end_date:
+ frappe.throw(_("From Fiscal Year cannot be greater than To Fiscal Year"))
def validate_duplicate(self):
budget_against_field = frappe.scrub(self.budget_against)
budget_against = self.get(budget_against_field)
+ account = self.account
+
+ if not account:
+ return
- accounts = [d.account for d in self.accounts] or []
existing_budget = frappe.db.sql(
- """
- select
- b.name, ba.account from `tabBudget` b, `tabBudget Account` ba
- where
- ba.parent = b.name and b.docstatus < 2 and b.company = {} and {}={} and
- b.fiscal_year={} and b.name != {} and ba.account in ({}) """.format(
- "%s", budget_against_field, "%s", "%s", "%s", ",".join(["%s"] * len(accounts))
- ),
- (self.company, budget_against, self.fiscal_year, self.name, *tuple(accounts)),
- as_dict=1,
+ 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, self.budget_end_date, self.budget_start_date),
+ as_dict=True,
)
- for d in existing_budget:
+ if existing_budget:
+ d = existing_budget[0]
frappe.throw(
_(
- "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' for fiscal year {4}"
- ).format(d.name, self.budget_against, budget_against, d.account, self.fiscal_year),
+ "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,
)
- def validate_accounts(self):
- account_list = []
- for d in self.get("accounts"):
- if d.account:
- account_details = frappe.get_cached_value(
- "Account", d.account, ["is_group", "company", "report_type"], as_dict=1
+ def validate_account(self):
+ if not self.account:
+ frappe.throw(_("Account is mandatory"))
+
+ account_details = frappe.get_cached_value(
+ "Account", self.account, ["is_group", "company", "report_type"], as_dict=1
+ )
+
+ if account_details.is_group:
+ frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(self.account))
+ elif account_details.company != self.company:
+ frappe.throw(_("Account {0} does not belong to company {1}").format(self.account, self.company))
+ elif account_details.report_type != "Profit and Loss":
+ frappe.throw(
+ _("Budget cannot be assigned against {0}, as it's not an Income or Expense account").format(
+ self.account
)
-
- if account_details.is_group:
- frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account))
- elif account_details.company != self.company:
- frappe.throw(
- _("Account {0} does not belongs to company {1}").format(d.account, self.company)
- )
- elif account_details.report_type != "Profit and Loss":
- frappe.throw(
- _(
- "Budget cannot be assigned against {0}, as it's not an Income or Expense account"
- ).format(d.account)
- )
-
- if d.account in account_list:
- frappe.throw(_("Account {0} has been entered multiple times").format(d.account))
- else:
- account_list.append(d.account)
+ )
def set_null_value(self):
if self.budget_against == "Cost Center":
@@ -139,30 +187,201 @@ class Budget(Document):
):
self.applicable_on_booking_actual_expenses = 1
+ def validate_existing_expenses(self):
+ if self.is_new() and self.revision_of:
+ return
-def validate_expense_against_budget(args, expense_amount=0):
- args = frappe._dict(args)
+ params = frappe._dict(
+ {
+ "company": self.company,
+ "account": self.account,
+ "budget_start_date": self.budget_start_date,
+ "budget_end_date": self.budget_end_date,
+ "budget_against_field": frappe.scrub(self.budget_against),
+ "budget_against_doctype": frappe.unscrub(self.budget_against),
+ }
+ )
+
+ params[params.budget_against_field] = self.get(params.budget_against_field)
+
+ if frappe.get_cached_value("DocType", params.budget_against_doctype, "is_tree"):
+ params.is_tree = True
+ else:
+ params.is_tree = False
+
+ actual_spent = get_actual_expense(params)
+
+ if actual_spent > self.budget_amount:
+ frappe.throw(
+ _(
+ "Spending for Account {0} ({1}) between {2} and {3} "
+ "has already exceeded the new allocated budget. "
+ "Spent: {4}, Budget: {5}"
+ ).format(
+ frappe.bold(self.account),
+ frappe.bold(self.company),
+ frappe.bold(self.budget_start_date),
+ frappe.bold(self.budget_end_date),
+ frappe.bold(frappe.utils.fmt_money(actual_spent)),
+ frappe.bold(frappe.utils.fmt_money(self.budget_amount)),
+ ),
+ title=_("Budget Limit Exceeded"),
+ )
+
+ def before_save(self):
+ self.allocate_budget()
+
+ def on_update(self):
+ self.validate_distribution_totals()
+
+ def allocate_budget(self):
+ if self.revision_of:
+ return
+
+ if not self.should_regenerate_budget_distribution():
+ return
+
+ self.set("budget_distribution", [])
+
+ periods = self.get_budget_periods()
+ total_periods = len(periods)
+ row_percent = 100 / total_periods if total_periods else 0
+
+ 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)
+
+ 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 not old_doc or not self.budget_distribution:
+ return True
+
+ if old_doc:
+ changed_fields = [
+ "from_fiscal_year",
+ "to_fiscal_year",
+ "budget_amount",
+ "distribution_frequency",
+ "distribute_equally",
+ ]
+ for field in changed_fields:
+ if old_doc.get(field) != self.get(field):
+ return True
+
+ return bool(self.distribute_equally)
+
+ def get_budget_periods(self):
+ """Return list of (start_date, end_date) tuples based on frequency."""
+ frequency = self.distribution_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, 3)
+ row.percent = flt(row_percent, 3)
+
+ def validate_distribution_totals(self):
+ if self.should_regenerate_budget_distribution():
+ return
+
+ total_amount = sum(d.amount for d in self.budget_distribution)
+ total_percent = sum(d.percent for d in self.budget_distribution)
+
+ if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
+ frappe.throw(
+ _("Total distributed amount {0} must be equal to Budget Amount {1}").format(
+ flt(total_amount, 2), self.budget_amount
+ )
+ )
+
+ if flt(abs(total_percent - 100), 2) > 0.10:
+ frappe.throw(
+ _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2))
+ )
+
+
+def validate_expense_against_budget(params, expense_amount=0):
+ params = frappe._dict(params)
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]
+ if not params.fiscal_year:
+ params.fiscal_year = get_fiscal_year(params.get("posting_date"), company=params.get("company"))[0]
- if args.get("company"):
- frappe.flags.exception_approver_role = frappe.get_cached_value(
- "Company", args.get("company"), "exception_budget_approver_role"
- )
+ posting_date = getdate(params.get("posting_date"))
+ posting_fiscal_year = get_fiscal_year(posting_date, company=params.get("company"))[0]
+ year_start_date, year_end_date = get_fiscal_year_date_range(posting_fiscal_year, posting_fiscal_year)
- if not frappe.db.get_value("Budget", {"fiscal_year": args.fiscal_year, "company": args.company}):
+ 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
+ """,
+ (params.company, year_end_date, year_start_date),
+ )
+
+ if not budget_exists:
return
- if not args.account:
- args.account = args.get("expense_account")
+ if params.get("company"):
+ frappe.flags.exception_approver_role = frappe.get_cached_value(
+ "Company", params.get("company"), "exception_budget_approver_role"
+ )
- if not (args.get("account") and args.get("cost_center")) and args.item_code:
- args.cost_center, args.account = get_item_details(args)
+ if not params.account:
+ params.account = params.get("expense_account")
- if not args.account:
+ if not params.get("expense_account") and params.get("account"):
+ params.expense_account = params.account
+
+ if not (params.get("account") and params.get("cost_center")) and params.item_code:
+ params.cost_center, params.account = get_item_details(params)
+
+ if not params.account:
return
default_dimensions = [
@@ -180,59 +399,78 @@ def validate_expense_against_budget(args, expense_amount=0):
budget_against = dimension.get("fieldname")
if (
- args.get(budget_against)
- and args.account
- and (frappe.get_cached_value("Account", args.account, "root_type") == "Expense")
+ params.get(budget_against)
+ and params.account
+ and (frappe.get_cached_value("Account", params.account, "root_type") == "Expense")
):
doctype = dimension.get("document_type")
if frappe.get_cached_value("DocType", doctype, "is_tree"):
- lft, rgt = frappe.get_cached_value(doctype, args.get(budget_against), ["lft", "rgt"])
+ lft, rgt = frappe.get_cached_value(doctype, params.get(budget_against), ["lft", "rgt"])
condition = f"""and exists(select name from `tab{doctype}`
where lft<={lft} and rgt>={rgt} and name=b.{budget_against})""" # nosec
- args.is_tree = True
+ params.is_tree = True
else:
- condition = f"and b.{budget_against}={frappe.db.escape(args.get(budget_against))}"
- args.is_tree = False
+ condition = f"and b.{budget_against}={frappe.db.escape(params.get(budget_against))}"
+ params.is_tree = False
- args.budget_against_field = budget_against
- args.budget_against_doctype = doctype
+ params.budget_against_field = budget_against
+ params.budget_against_doctype = doctype
budget_records = frappe.db.sql(
f"""
- select
- b.{budget_against} as budget_against, ba.budget_amount, b.monthly_distribution,
- 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
- `tabBudget` b, `tabBudget Account` ba
- where
- b.name=ba.parent and b.fiscal_year=%s
- and ba.account=%s and b.docstatus=1
+ SELECT
+ b.name,
+ b.{budget_against} AS budget_against,
+ b.budget_amount,
+ b.from_fiscal_year,
+ b.to_fiscal_year,
+ b.budget_start_date,
+ b.budget_end_date,
+ 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.company = %s
+ AND b.docstatus = 1
+ AND %s BETWEEN b.budget_start_date AND b.budget_end_date
+ AND b.account = %s
{condition}
- """,
- (args.fiscal_year, args.account),
+ """,
+ (params.company, params.posting_date, params.account),
as_dict=True,
) # nosec
if budget_records:
- validate_budget_records(args, budget_records, expense_amount)
+ validate_budget_records(params, budget_records, expense_amount)
-def validate_budget_records(args, budget_records, expense_amount):
+def validate_budget_records(params, budget_records, expense_amount):
for budget in budget_records:
if flt(budget.budget_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
+ yearly_action, monthly_action = get_actions(params, budget)
+ params["for_material_request"] = budget.for_material_request
+ params["for_purchase_order"] = budget.for_purchase_order
+ params["from_fiscal_year"], params["to_fiscal_year"] = (
+ budget.from_fiscal_year,
+ budget.to_fiscal_year,
+ )
+ params["budget_start_date"], params["budget_end_date"] = (
+ budget.budget_start_date,
+ budget.budget_end_date,
+ )
if yearly_action in ("Stop", "Warn"):
compare_expense_with_budget(
- args,
+ params,
flt(budget.budget_amount),
_("Annual"),
yearly_action,
@@ -241,14 +479,12 @@ def validate_budget_records(args, budget_records, expense_amount):
)
if monthly_action in ["Stop", "Warn"]:
- budget_amount = get_accumulated_monthly_budget(
- budget.monthly_distribution, args.posting_date, args.fiscal_year, budget.budget_amount
- )
+ budget_amount = get_accumulated_monthly_budget(budget.name, params.posting_date)
- args["month_end_date"] = get_last_day(args.posting_date)
+ params["month_end_date"] = get_last_day(params.posting_date)
compare_expense_with_budget(
- args,
+ params,
budget_amount,
_("Accumulated Monthly"),
monthly_action,
@@ -257,38 +493,41 @@ def validate_budget_records(args, budget_records, expense_amount):
)
-def compare_expense_with_budget(args, budget_amount, action_for, action, budget_against, amount=0):
- args.actual_expense, args.requested_amount, args.ordered_amount = get_actual_expense(args), 0, 0
+def compare_expense_with_budget(params, budget_amount, action_for, action, budget_against, amount=0):
+ params.actual_expense, params.requested_amount, params.ordered_amount = get_actual_expense(params), 0, 0
if not amount:
- args.requested_amount, args.ordered_amount = get_requested_amount(args), get_ordered_amount(args)
+ params.requested_amount, params.ordered_amount = (
+ get_requested_amount(params),
+ get_ordered_amount(params),
+ )
- if args.get("doctype") == "Material Request" and args.for_material_request:
- amount = args.requested_amount + args.ordered_amount
+ if params.get("doctype") == "Material Request" and params.for_material_request:
+ amount = params.requested_amount + params.ordered_amount
- elif args.get("doctype") == "Purchase Order" and args.for_purchase_order:
- amount = args.ordered_amount
+ elif params.get("doctype") == "Purchase Order" and params.for_purchase_order:
+ amount = params.ordered_amount
- total_expense = args.actual_expense + amount
+ total_expense = params.actual_expense + amount
if total_expense > budget_amount:
- if args.actual_expense > budget_amount:
- diff = args.actual_expense - budget_amount
+ if params.actual_expense > budget_amount:
+ diff = params.actual_expense - budget_amount
_msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It is already exceeded by {5}.")
else:
diff = total_expense - budget_amount
_msg = _("{0} Budget for Account {1} against {2} {3} is {4}. It will be exceeded by {5}.")
- currency = frappe.get_cached_value("Company", args.company, "default_currency")
+ currency = frappe.get_cached_value("Company", params.company, "default_currency")
msg = _msg.format(
_(action_for),
- frappe.bold(args.account),
- frappe.unscrub(args.budget_against_field),
+ frappe.bold(params.account),
+ frappe.unscrub(params.budget_against_field),
frappe.bold(budget_against),
frappe.bold(fmt_money(budget_amount, currency=currency)),
frappe.bold(fmt_money(diff, currency=currency)),
)
- msg += get_expense_breakup(args, currency, budget_against)
+ msg += get_expense_breakup(params, currency, budget_against)
if frappe.flags.exception_approver_role and frappe.flags.exception_approver_role in frappe.get_roles(
frappe.session.user
@@ -301,14 +540,25 @@ def compare_expense_with_budget(args, budget_amount, action_for, action, budget_
frappe.msgprint(msg, indicator="orange", title=_("Budget Exceeded"))
-def get_expense_breakup(args, currency, budget_against):
- msg = "
{{ _('Total Expenses booked through') }} - "
+def get_expense_breakup(params, currency, budget_against):
+ msg = "
{} - ".format(_("Total Expenses booked through"))
common_filters = frappe._dict(
{
- args.budget_against_field: budget_against,
- "account": args.account,
- "company": args.company,
+ params.budget_against_field: budget_against,
+ "account": params.account,
+ "company": params.company,
+ }
+ )
+
+ from_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
+ to_date = frappe.get_cached_value("Fiscal Year", params.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,
}
)
@@ -317,18 +567,23 @@ def get_expense_breakup(args, currency, budget_against):
+ 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))
+ + frappe.bold(fmt_money(params.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": params.item_code,
+ "per_ordered": [["<", 100]],
+ }
+ )
msg += (
"- "
@@ -337,22 +592,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))
+ + frappe.bold(fmt_money(params.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": params.item_code,
+ "per_billed": [["<", 100]],
+ }
+ )
+
msg += (
"- "
+ frappe.utils.get_link_to_report(
@@ -360,42 +617,34 @@ 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))
+ + frappe.bold(fmt_money(params.ordered_amount, currency=currency))
+ "
"
)
return msg
-def get_actions(args, budget):
+def get_actions(params, budget):
yearly_action = budget.action_if_annual_budget_exceeded
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded
- if args.get("doctype") == "Material Request" and budget.for_material_request:
+ if params.get("doctype") == "Material Request" and budget.for_material_request:
yearly_action = budget.action_if_annual_budget_exceeded_on_mr
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_mr
- elif args.get("doctype") == "Purchase Order" and budget.for_purchase_order:
+ elif params.get("doctype") == "Purchase Order" and budget.for_purchase_order:
yearly_action = budget.action_if_annual_budget_exceeded_on_po
monthly_action = budget.action_if_accumulated_monthly_budget_exceeded_on_po
return yearly_action, monthly_action
-def get_requested_amount(args):
- item_code = args.get("item_code")
- condition = get_other_condition(args, "Material Request")
+def get_requested_amount(params):
+ item_code = params.get("item_code")
+ condition = get_other_condition(params, "Material Request")
data = frappe.db.sql(
""" select ifnull((sum(child.stock_qty - child.ordered_qty) * rate), 0) as amount
@@ -409,9 +658,9 @@ def get_requested_amount(args):
return data[0][0] if data else 0
-def get_ordered_amount(args):
- item_code = args.get("item_code")
- condition = get_other_condition(args, "Purchase Order")
+def get_ordered_amount(params):
+ item_code = params.get("item_code")
+ condition = get_other_condition(params, "Purchase Order")
data = frappe.db.sql(
f""" select ifnull(sum(child.amount - child.billed_amt), 0) as amount
@@ -425,111 +674,102 @@ def get_ordered_amount(args):
return data[0][0] if data else 0
-def get_other_condition(args, for_doc):
- condition = "expense_account = '%s'" % (args.expense_account)
- budget_against_field = args.get("budget_against_field")
+def get_other_condition(params, for_doc):
+ condition = f"expense_account = '{params.expense_account}'"
+ budget_against_field = params.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 budget_against_field and params.get(budget_against_field):
+ condition += f" and child.{budget_against_field} = '{params.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", params.from_fiscal_year, "year_start_date")
+ end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
+
+ condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
return condition
-def get_actual_expense(args):
- if not args.budget_against_doctype:
- args.budget_against_doctype = frappe.unscrub(args.budget_against_field)
+def get_actual_expense(params):
+ if not params.budget_against_doctype:
+ params.budget_against_doctype = frappe.unscrub(params.budget_against_field)
- budget_against_field = args.get("budget_against_field")
- condition1 = " and gle.posting_date <= %(month_end_date)s" if args.get("month_end_date") else ""
+ budget_against_field = params.get("budget_against_field")
+ condition1 = " and gle.posting_date <= %(month_end_date)s" if params.get("month_end_date") else ""
- if args.is_tree:
+ date_condition = (
+ f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'"
+ )
+
+ if params.is_tree:
lft_rgt = frappe.db.get_value(
- args.budget_against_doctype, args.get(budget_against_field), ["lft", "rgt"], as_dict=1
+ params.budget_against_doctype, params.get(budget_against_field), ["lft", "rgt"], as_dict=1
)
+ params.update(lft_rgt)
- 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{params.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}
+ """,
+ params,
)[0][0]
) # nosec
return amount
-def get_accumulated_monthly_budget(monthly_distribution, posting_date, fiscal_year, annual_budget):
- distribution = {}
- if monthly_distribution:
- mdp = frappe.qb.DocType("Monthly Distribution Percentage")
- md = frappe.qb.DocType("Monthly Distribution")
+def get_accumulated_monthly_budget(budget_name, posting_date):
+ posting_date = getdate(posting_date)
- res = (
- frappe.qb.from_(mdp)
- .join(md)
- .on(mdp.parent == md.name)
- .select(mdp.month, mdp.percentage_allocation)
- .where(md.fiscal_year == fiscal_year)
- .where(md.name == monthly_distribution)
- .run(as_dict=True)
- )
+ bd = frappe.qb.DocType("Budget Distribution")
+ b = frappe.qb.DocType("Budget")
- for d in res:
- distribution.setdefault(d.month, d.percentage_allocation)
+ result = (
+ frappe.qb.from_(bd)
+ .join(b)
+ .on(bd.parent == b.name)
+ .select(Sum(bd.amount).as_("accumulated_amount"))
+ .where(b.name == budget_name)
+ .where(bd.start_date <= posting_date)
+ .run(as_dict=True)
+ )
- dt = frappe.get_cached_value("Fiscal Year", fiscal_year, "year_start_date")
- accumulated_percentage = 0.0
-
- while dt <= getdate(posting_date):
- if monthly_distribution and distribution:
- accumulated_percentage += distribution.get(getdate(dt).strftime("%B"), 0)
- else:
- accumulated_percentage += 100.0 / 12
-
- dt = add_months(dt, 1)
-
- return annual_budget * accumulated_percentage / 100
+ return flt(result[0]["accumulated_amount"]) if result else 0.0
-def get_item_details(args):
+def get_item_details(params):
cost_center, expense_account = None, None
- if not args.get("company"):
+ if not params.get("company"):
return cost_center, expense_account
- if args.item_code:
+ if params.item_code:
item_defaults = frappe.db.get_value(
"Item Default",
- {"parent": args.item_code, "company": args.get("company")},
+ {"parent": params.item_code, "company": params.get("company")},
["buying_cost_center", "expense_account"],
)
if item_defaults:
@@ -537,7 +777,7 @@ def get_item_details(args):
if not (cost_center and expense_account):
for doctype in ["Item Group", "Company"]:
- data = get_expense_cost_center(doctype, args)
+ data = get_expense_cost_center(doctype, params)
if not cost_center and data:
cost_center = data[0]
@@ -551,14 +791,39 @@ def get_item_details(args):
return cost_center, expense_account
-def get_expense_cost_center(doctype, args):
+def get_expense_cost_center(doctype, params):
if doctype == "Item Group":
return frappe.db.get_value(
"Item Default",
- {"parent": args.get(frappe.scrub(doctype)), "company": args.get("company")},
+ {"parent": params.get(frappe.scrub(doctype)), "company": params.get("company")},
["buying_cost_center", "expense_account"],
)
else:
return frappe.db.get_value(
- doctype, args.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"]
+ doctype, params.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"]
)
+
+
+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)
+
+ if old_budget.docstatus == 1:
+ old_budget.cancel()
+
+ new_budget = frappe.copy_doc(old_budget)
+ new_budget.docstatus = 0
+ new_budget.revision_of = old_budget.name
+ 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 ccc92fb518b..ba9b4c04e08 100644
--- a/erpnext/accounts/doctype/budget/test_budget.py
+++ b/erpnext/accounts/doctype/budget/test_budget.py
@@ -3,12 +3,14 @@
import unittest
import frappe
-from frappe.utils import 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,
get_accumulated_monthly_budget,
get_actual_expense,
+ revise_budget,
)
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_fiscal_year
@@ -25,11 +27,15 @@ class TestBudget(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False)
+ self.company = "_Test Company"
+ self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name")
+ self.account = "_Test Account Cost for Goods Sold - _TC"
+ self.cost_center = "_Test Cost Center - _TC"
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",
@@ -50,12 +56,13 @@ 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")
accumulated_limit = get_accumulated_monthly_budget(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -73,13 +80,11 @@ 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")
- accumulated_limit = get_accumulated_monthly_budget(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
- )
+ accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate())
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
"_Test Bank - _TC",
@@ -107,16 +112,16 @@ 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,
+ 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.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
-
mr = frappe.get_doc(
{
"doctype": "Material Request",
@@ -151,14 +156,15 @@ 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.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
po = create_purchase_order(
transaction_date=nowdate(), qty=1, rate=accumulated_limit + 1, do_not_submit=True
@@ -175,13 +181,14 @@ 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")
project = frappe.get_value("Project", {"project_name": "_Test Project"})
accumulated_limit = get_accumulated_monthly_budget(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -200,7 +207,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",
@@ -217,7 +224,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"})
@@ -237,7 +244,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
@@ -266,7 +273,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
@@ -298,11 +305,17 @@ 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(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -331,11 +344,14 @@ 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(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ budget.name,
+ nowdate(),
)
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
@@ -372,7 +388,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",
@@ -387,12 +408,15 @@ 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)
-
- accumulated_limit = get_accumulated_monthly_budget(
- budget.monthly_distribution, nowdate(), budget.fiscal_year, budget.accounts[0].budget_amount
+ 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())
+
jv = make_journal_entry(
"_Test Account Cost for Goods Sold - _TC",
"_Test Bank - _TC",
@@ -422,6 +446,165 @@ class TestBudget(ERPNextTestSuite):
po.cancel()
jv.cancel()
+ def test_fiscal_year_validation(self):
+ frappe.get_doc(
+ {
+ "doctype": "Fiscal Year",
+ "year": "2100",
+ "year_start_date": "2100-04-01",
+ "year_end_date": "2101-03-31",
+ "companies": [{"company": "_Test Company"}],
+ }
+ ).insert(ignore_permissions=True)
+
+ budget = make_budget(
+ budget_against="Cost Center",
+ from_fiscal_year="2100",
+ to_fiscal_year="2099",
+ do_not_save=True,
+ submit_budget=False,
+ )
+
+ with self.assertRaises(frappe.ValidationError):
+ budget.save()
+
+ def test_total_distribution_equals_budget(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ applicable_on_cumulative_expense=True,
+ distribute_equally=0,
+ budget_amount=12000,
+ do_not_save=False,
+ submit_budget=False,
+ )
+
+ for row in budget.budget_distribution:
+ row.amount = 2000
+
+ with self.assertRaises(frappe.ValidationError):
+ budget.save()
+
+ def test_evenly_distribute_budget(self):
+ 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(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, do_not_save=False, submit_budget=True
+ )
+
+ revised_name = revise_budget(budget.name)
+
+ revised_budget = frappe.get_doc("Budget", revised_name)
+ self.assertNotEqual(budget.name, revised_budget.name)
+ self.assertEqual(revised_budget.budget_against, budget.budget_against)
+ self.assertEqual(revised_budget.budget_amount, budget.budget_amount)
+
+ old_budget = frappe.get_doc("Budget", budget.name)
+ self.assertEqual(old_budget.docstatus, 2)
+
+ def test_revision_preserves_distribution(self):
+ set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center - _TC")
+ 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)
+
+ self.assertGreater(len(revised_budget.budget_distribution), 0)
+
+ 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=False,
+ submit_budget=False,
+ )
+
+ budget.budget_distribution = []
+
+ for row in [
+ {"start_date": "2025-04-01", "end_date": "2025-04-30", "amount": 10000, "percent": 33.33},
+ {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000, "percent": 50.00},
+ {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000, "percent": 16.67},
+ ]:
+ 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 test_fiscal_year_company_mismatch(self):
+ budget = make_budget(budget_against="Cost Center", do_not_save=True, submit_budget=False)
+
+ fy = frappe.get_doc(
+ {
+ "doctype": "Fiscal Year",
+ "year": "2099",
+ "year_start_date": "2099-04-01",
+ "year_end_date": "2100-03-31",
+ "companies": [{"company": "_Test Company 2"}],
+ }
+ ).insert(ignore_permissions=True)
+
+ budget.from_fiscal_year = fy.name
+ budget.to_fiscal_year = fy.name
+ budget.company = "_Test Company"
+
+ with self.assertRaises(frappe.ValidationError):
+ budget.save()
+
+ def test_manual_distribution_total_equals_budget_amount(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ cost_center="_Test Cost Center - _TC",
+ distribute_equally=0,
+ budget_amount=12000,
+ do_not_save=False,
+ submit_budget=False,
+ )
+
+ for d in budget.budget_distribution:
+ d.amount = 2000
+
+ with self.assertRaises(frappe.ValidationError):
+ budget.save()
+
+ def test_duplicate_budget_validation(self):
+ budget = make_budget(
+ budget_against="Cost Center",
+ distribute_equally=1,
+ budget_amount=15000,
+ do_not_save=False,
+ submit_budget=True,
+ )
+
+ new_budget = frappe.new_doc("Budget")
+ new_budget.company = "_Test Company"
+ new_budget.from_fiscal_year = budget.from_fiscal_year
+ new_budget.to_fiscal_year = new_budget.from_fiscal_year
+ new_budget.budget_against = "Cost Center"
+ new_budget.cost_center = "_Test Cost Center - _TC"
+ new_budget.account = "_Test Account Cost for Goods Sold - _TC"
+ new_budget.budget_amount = 10000
+
+ with self.assertRaises(frappe.ValidationError):
+ new_budget.insert()
+
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":
@@ -430,21 +613,32 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again
budget_against = budget_against_CC or "_Test Cost Center - _TC"
fiscal_year = get_fiscal_year(nowdate())[0]
+ fiscal_year_start_date, fiscal_year_end_date = get_fiscal_year(nowdate())[1:3]
args = frappe._dict(
{
"account": "_Test Account Cost for Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
- "monthly_end_date": posting_date,
+ "month_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,
+ "budget_start_date": fiscal_year_start_date,
+ "budget_end_date": fiscal_year_end_date,
}
)
if not args.get(budget_against_field):
args[budget_against_field] = budget_against
+ args.budget_against_doctype = frappe.unscrub(budget_against_field)
+
+ if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"):
+ args.is_tree = True
+ else:
+ args.is_tree = False
+
existing_expense = get_actual_expense(args)
if existing_expense:
@@ -474,18 +668,33 @@ def make_budget(**args):
budget_against = args.budget_against
cost_center = args.cost_center
-
fiscal_year = get_fiscal_year(nowdate())[0]
if budget_against == "Project":
- project_name = "{}%".format("_Test Project/" + fiscal_year)
- budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", project_name)})
+ project = frappe.get_value("Project", {"project_name": "_Test Project"})
+ budget_list = frappe.get_all(
+ "Budget",
+ filters={
+ "project": project,
+ "account": "_Test Account Cost for Goods Sold - _TC",
+ },
+ pluck="name",
+ )
else:
- cost_center_name = "{}%".format(cost_center or "_Test Cost Center - _TC/" + fiscal_year)
- budget_list = frappe.get_all("Budget", fields=["name"], filters={"name": ("like", cost_center_name)})
- for d in budget_list:
- frappe.db.sql("delete from `tabBudget` where name = %(name)s", d)
- frappe.db.sql("delete from `tabBudget Account` where parent = %(name)s", d)
+ budget_list = frappe.get_all(
+ "Budget",
+ filters={
+ "cost_center": cost_center or "_Test Cost Center - _TC",
+ "account": "_Test Account Cost for Goods Sold - _TC",
+ },
+ pluck="name",
+ )
+
+ for name in budget_list:
+ doc = frappe.get_doc("Budget", name)
+ if doc.docstatus == 1:
+ doc.cancel()
+ frappe.delete_doc("Budget", name, force=True, ignore_missing=True)
budget = frappe.new_doc("Budget")
@@ -494,18 +703,18 @@ def make_budget(**args):
else:
budget.cost_center = cost_center or "_Test Cost Center - _TC"
- monthly_distribution = frappe.get_doc("Monthly Distribution", "_Test Distribution")
- monthly_distribution.fiscal_year = fiscal_year
- monthly_distribution.save()
-
- budget.fiscal_year = fiscal_year
- budget.monthly_distribution = "_Test Distribution"
+ budget.from_fiscal_year = args.from_fiscal_year or fiscal_year
+ budget.to_fiscal_year = args.to_fiscal_year or fiscal_year
budget.company = "_Test Company"
+ budget.account = "_Test Account Cost for Goods Sold - _TC"
+ budget.budget_amount = args.budget_amount or 200000
budget.applicable_on_booking_actual_expenses = 1
budget.action_if_annual_budget_exceeded = "Stop"
budget.action_if_accumulated_monthly_budget_exceeded = "Ignore"
budget.budget_against = budget_against
- budget.append("accounts", {"account": "_Test Account Cost for Goods Sold - _TC", "budget_amount": 200000})
+
+ budget.distribution_frequency = "Monthly"
+ budget.distribute_equally = args.get("distribute_equally", 1)
if args.applicable_on_material_request:
budget.applicable_on_material_request = 1
@@ -530,7 +739,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/__init__.py b/erpnext/accounts/doctype/budget_distribution/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
new file mode 100644
index 00000000000..85d14599cec
--- /dev/null
+++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json
@@ -0,0 +1,58 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-10-12 23:31:03.841996",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "start_date",
+ "end_date",
+ "amount",
+ "percent"
+ ],
+ "fields": [
+ {
+ "fieldname": "start_date",
+ "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",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount"
+ },
+ {
+ "fieldname": "percent",
+ "fieldtype": "Percent",
+ "in_list_view": 1,
+ "label": "Percent"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-11-03 13:18:28.398198",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Budget Distribution",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.py b/erpnext/accounts/doctype/budget_distribution/budget_distribution.py
new file mode 100644
index 00000000000..4c2cb3bb1bf
--- /dev/null
+++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BudgetDistribution(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amount: DF.Currency
+ end_date: DF.Date | None
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ percent: DF.Percent
+ start_date: DF.Date | None
+ # end: auto-generated types
+
+ pass
diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py
index 6a9e6ae316d..5c7692a4433 100644
--- a/erpnext/controllers/budget_controller.py
+++ b/erpnext/controllers/budget_controller.py
@@ -59,10 +59,8 @@ class BudgetValidation:
_obj.update(
{
"accumulated_monthly_budget": get_accumulated_monthly_budget(
- self.budget_map[key].monthly_distribution,
+ self.budget_map[key].name,
self.doc_date,
- self.fiscal_year,
- self.budget_map[key].budget_amount,
)
}
)
@@ -164,16 +162,19 @@ class BudgetValidation:
def get_budget_records(self) -> list:
bud = qb.DocType("Budget")
- bud_acc = qb.DocType("Budget Account")
+
query = (
qb.from_(bud)
- .inner_join(bud_acc)
- .on(bud.name == bud_acc.parent)
.select(
bud.name,
bud.budget_against,
bud.company,
- bud.monthly_distribution,
+ bud.account,
+ bud.budget_amount,
+ bud.from_fiscal_year,
+ bud.to_fiscal_year,
+ bud.budget_start_date,
+ bud.budget_end_date,
bud.applicable_on_material_request,
bud.action_if_annual_budget_exceeded_on_mr,
bud.action_if_accumulated_monthly_budget_exceeded_on_mr,
@@ -186,13 +187,15 @@ class BudgetValidation:
bud.applicable_on_cumulative_expense,
bud.action_if_annual_exceeded_on_cumulative_expense,
bud.action_if_accumulated_monthly_exceeded_on_cumulative_expense,
- bud_acc.account,
- bud_acc.budget_amount,
)
- .where(bud.docstatus.eq(1) & bud.fiscal_year.eq(self.fiscal_year) & bud.company.eq(self.company))
+ .where(
+ (bud.docstatus == 1)
+ & (bud.company == self.company)
+ & (bud.budget_start_date <= self.doc_date)
+ & (bud.budget_end_date >= self.doc_date)
+ )
)
- # add dimension fields
for x in self.dimensions:
query = query.select(bud[x.get("fieldname")])
@@ -314,8 +317,8 @@ class BudgetValidation:
frappe.bold(key[2]),
frappe.bold(frappe.unscrub(key[0])),
frappe.bold(key[1]),
- frappe.bold(fmt_money(annual_diff, currency=currency)),
frappe.bold(fmt_money(budget_amt, currency=currency)),
+ frappe.bold(fmt_money(annual_diff, currency=currency)),
)
self.execute_action(config.action_for_annual, _msg)
@@ -425,7 +428,7 @@ class BudgetValidation:
frappe.bold(key[2]),
frappe.bold(frappe.unscrub(key[0])),
frappe.bold(key[1]),
- frappe.bold(fmt_money(v_map.accumulated_montly_budget, currency=currency)),
+ frappe.bold(fmt_money(v_map.accumulated_monthly_budget, currency=currency)),
self.budget_applicable_for(v_map, current_amt),
frappe.bold(fmt_money(monthly_diff, currency=currency)),
)
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 51d4f66b9bb..74f6e8a275b 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -448,4 +448,4 @@ erpnext.patches.v16_0.update_serial_batch_entries
erpnext.patches.v16_0.set_company_wise_warehouses
erpnext.patches.v16_0.set_valuation_method_on_companies
erpnext.patches.v15_0.migrate_old_item_wise_tax_detail_data_to_table
-
+erpnext.patches.v16_0.migrate_budget_records_to_new_structure
diff --git a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py
new file mode 100644
index 00000000000..c9a18ebff31
--- /dev/null
+++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py
@@ -0,0 +1,128 @@
+import frappe
+from frappe.utils import add_months, flt, get_first_day, get_last_day
+
+
+def execute():
+ remove_old_property_setter()
+
+ budget_names = frappe.db.get_list(
+ "Budget",
+ filters={"docstatus": ["in", [0, 1]]},
+ pluck="name",
+ )
+
+ for budget in budget_names:
+ migrate_single_budget(budget)
+
+
+def remove_old_property_setter():
+ old_property_setter = frappe.db.get_value(
+ "Property Setter",
+ {
+ "doc_type": "Budget",
+ "field_name": "naming_series",
+ "property": "options",
+ "value": "Budget-.YYYY.-",
+ },
+ "name",
+ )
+
+ if old_property_setter:
+ frappe.delete_doc("Property Setter", old_property_setter, force=1)
+
+
+def migrate_single_budget(budget_name):
+ budget_doc = frappe.get_doc("Budget", budget_name)
+
+ account_rows = frappe.get_all(
+ "Budget Account",
+ filters={"parent": budget_name},
+ fields=["account", "budget_amount"],
+ order_by="idx asc",
+ )
+
+ if not account_rows:
+ return
+
+ frappe.db.delete("Budget Account", filters={"parent": budget_doc.name})
+
+ percentage_allocations = get_percentage_allocations(budget_doc)
+
+ fiscal_year = frappe.get_cached_value(
+ "Fiscal Year",
+ budget_doc.fiscal_year,
+ ["name", "year_start_date", "year_end_date"],
+ as_dict=True,
+ )
+
+ for row in account_rows:
+ create_new_budget_from_row(budget_doc, fiscal_year, row, percentage_allocations)
+
+ if budget_doc.docstatus == 1:
+ budget_doc.cancel()
+ else:
+ frappe.delete_doc("Budget", budget_name)
+
+
+def get_percentage_allocations(budget_doc):
+ if budget_doc.monthly_distribution:
+ distribution_doc = frappe.get_cached_doc("Monthly Distribution", budget_doc.monthly_distribution)
+ return [flt(row.percentage_allocation) for row in distribution_doc.percentages]
+
+ return [100 / 12] * 12
+
+
+def create_new_budget_from_row(budget_doc, fiscal_year, account_row, percentage_allocations):
+ new_budget = frappe.new_doc("Budget")
+
+ core_fields = ["budget_against", "company", "cost_center", "project"]
+ for field in core_fields:
+ new_budget.set(field, budget_doc.get(field))
+
+ new_budget.from_fiscal_year = fiscal_year.name
+ new_budget.to_fiscal_year = fiscal_year.name
+ new_budget.budget_start_date = fiscal_year.year_start_date
+ new_budget.budget_end_date = fiscal_year.year_end_date
+
+ new_budget.account = account_row.account
+ new_budget.budget_amount = flt(account_row.budget_amount)
+ new_budget.distribution_frequency = "Monthly"
+ new_budget.distribute_equally = 1 if len(set(percentage_allocations)) == 1 else 0
+
+ copy_fields = [
+ "applicable_on_material_request",
+ "action_if_annual_budget_exceeded_on_mr",
+ "action_if_accumulated_monthly_budget_exceeded_on_mr",
+ "applicable_on_purchase_order",
+ "action_if_annual_budget_exceeded_on_po",
+ "action_if_accumulated_monthly_budget_exceeded_on_po",
+ "applicable_on_booking_actual_expenses",
+ "action_if_annual_budget_exceeded",
+ "action_if_accumulated_monthly_budget_exceeded",
+ "applicable_on_cumulative_expense",
+ "action_if_annual_exceeded_on_cumulative_expense",
+ "action_if_accumulated_monthly_exceeded_on_cumulative_expense",
+ ]
+
+ for field in copy_fields:
+ new_budget.set(field, budget_doc.get(field))
+
+ current_start = fiscal_year.year_start_date
+ for percentage in percentage_allocations:
+ new_budget.append(
+ "budget_distribution",
+ {
+ "start_date": get_first_day(current_start),
+ "end_date": get_last_day(current_start),
+ "percent": percentage,
+ "amount": new_budget.budget_amount * percentage / 100,
+ },
+ )
+ current_start = add_months(current_start, 1)
+
+ new_budget.flags.ignore_validate = True
+ new_budget.flags.ignore_links = True
+ new_budget.insert(ignore_permissions=True, ignore_mandatory=True)
+
+ if budget_doc.docstatus == 1:
+ new_budget.submit()