From b6e452a695ed3a5734cfe0d5bfe23d9634206acb Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 6 Oct 2025 15:15:13 +0530 Subject: [PATCH 01/28] feat: show budget total --- erpnext/accounts/doctype/budget/budget.json | 29 ++++++++++++++++++--- erpnext/accounts/doctype/budget/budget.py | 5 ++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index fcd78691a03..5b62b7eec92 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -33,7 +33,11 @@ "action_if_annual_exceeded_on_cumulative_expense", "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "section_break_21", - "accounts" + "accounts", + "section_break_hqka", + "column_break_gnot", + "column_break_ybiq", + "total_budget_amount" ], "fields": [ { @@ -188,7 +192,8 @@ }, { "fieldname": "section_break_21", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "accounts", @@ -232,13 +237,31 @@ "fieldtype": "Select", "label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense", "options": "\nStop\nWarn\nIgnore" + }, + { + "fieldname": "section_break_hqka", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_gnot", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_ybiq", + "fieldtype": "Column Break" + }, + { + "fieldname": "total_budget_amount", + "fieldtype": "Currency", + "label": "Total Budget Amount", + "read_only": 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-10-06 14:55:07.247313", "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..2472e813cd1 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -53,6 +53,7 @@ class Budget(Document): monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None + total_budget_amount: DF.Currency # end: auto-generated types def validate(self): @@ -62,6 +63,7 @@ class Budget(Document): self.validate_accounts() self.set_null_value() self.validate_applicable_for() + self.set_total_budget_amount() def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) @@ -139,6 +141,9 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 + def set_total_budget_amount(self): + self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 906a4bd398f229d677f23674ac5c70998dc91a84 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 6 Oct 2025 17:24:52 +0530 Subject: [PATCH 02/28] feat(patch): set total budget amount on budget doctype --- erpnext/patches.txt | 2 +- erpnext/patches/v16_0/set_total_budget_amount.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v16_0/set_total_budget_amount.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 51d4f66b9bb..a894f8cb44a 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.set_total_budget_amount diff --git a/erpnext/patches/v16_0/set_total_budget_amount.py b/erpnext/patches/v16_0/set_total_budget_amount.py new file mode 100644 index 00000000000..163f0f17274 --- /dev/null +++ b/erpnext/patches/v16_0/set_total_budget_amount.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + if frappe.db.has_column("Budget", "total_budget_amount"): + frappe.db.sql( + """ + UPDATE `tabBudget` b + SET b.total_budget_amount = ( + SELECT SUM(ba.budget_amount) + FROM `tabBudget Account` ba + WHERE ba.parent = b.name + ) + WHERE IFNULL(b.total_budget_amount, 0) = 0 + """ + ) From e23d229e7bd3db25bc79a4167d2daba4337434c4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 13 Oct 2025 12:20:41 +0530 Subject: [PATCH 03/28] feat: introduce budget distribution child table --- erpnext/accounts/doctype/budget/budget.json | 16 +++++- erpnext/accounts/doctype/budget/budget.py | 2 + .../doctype/budget_distribution/__init__.py | 0 .../budget_distribution.json | 56 +++++++++++++++++++ .../budget_distribution.py | 26 +++++++++ 5 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 erpnext/accounts/doctype/budget_distribution/__init__.py create mode 100644 erpnext/accounts/doctype/budget_distribution/budget_distribution.json create mode 100644 erpnext/accounts/doctype/budget_distribution/budget_distribution.py diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 5b62b7eec92..ef347a37082 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -37,7 +37,9 @@ "section_break_hqka", "column_break_gnot", "column_break_ybiq", - "total_budget_amount" + "total_budget_amount", + "section_break_fpdt", + "budget_distribution" ], "fields": [ { @@ -255,13 +257,23 @@ "fieldtype": "Currency", "label": "Total Budget Amount", "read_only": 1 + }, + { + "fieldname": "section_break_fpdt", + "fieldtype": "Section Break" + }, + { + "fieldname": "budget_distribution", + "fieldtype": "Table", + "label": "Budget Distribution", + "options": "Budget Distribution" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-06 14:55:07.247313", + "modified": "2025-10-12 23:44:49.632709", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 2472e813cd1..3c7e2b04b8d 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -31,6 +31,7 @@ class Budget(Document): 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] action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] @@ -47,6 +48,7 @@ class Budget(Document): applicable_on_material_request: DF.Check applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] + budget_distribution: DF.Table[BudgetDistribution] company: DF.Link cost_center: DF.Link | None fiscal_year: DF.Link 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..5c633fa392b --- /dev/null +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -0,0 +1,56 @@ +{ + "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", + "search_index": 1 + }, + { + "fieldname": "end_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "End Date" + }, + { + "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-10-12 23:47:30.393908", + "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 From ccb89fee755b7b56f96b1077c27ee4dd594db432 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 13 Oct 2025 16:27:09 +0530 Subject: [PATCH 04/28] feat: add fields for new budget flow --- erpnext/accounts/doctype/budget/budget.json | 28 ++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index ef347a37082..bae88b9ffc5 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -16,6 +16,9 @@ "column_break_3", "monthly_distribution", "amended_from", + "account", + "distribution_type", + "allocation_frequency", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -267,13 +270,36 @@ "fieldtype": "Table", "label": "Budget Distribution", "options": "Budget Distribution" + }, + { + "fieldname": "account", + "fieldtype": "Link", + "label": "Account", + "options": "Account", + "reqd": 1 + }, + { + "default": "Percent", + "fieldname": "distribution_type", + "fieldtype": "Select", + "label": "Distribution Type", + "options": "Amount\nPercent", + "reqd": 1 + }, + { + "default": "Monthly", + "fieldname": "allocation_frequency", + "fieldtype": "Select", + "label": "Allocation Frequency", + "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", + "reqd": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-12 23:44:49.632709", + "modified": "2025-10-13 16:15:53.046278", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From 88570379717e16a2a6f7b6aac6687f78c05e245e Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 01:38:46 +0530 Subject: [PATCH 05/28] feat: flexible budget allocation frequency --- erpnext/accounts/doctype/budget/budget.json | 16 +++++++++++++++- erpnext/accounts/doctype/budget/budget.py | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index bae88b9ffc5..129ecd31be8 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -19,6 +19,8 @@ "account", "distribution_type", "allocation_frequency", + "budget_start_date", + "budget_end_date", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -286,6 +288,18 @@ "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", + "reqd": 1 + }, { "default": "Monthly", "fieldname": "allocation_frequency", @@ -299,7 +313,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-13 16:15:53.046278", + "modified": "2025-10-15 01:20:30.551362", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 3c7e2b04b8d..0c4cb18087d 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -2,10 +2,12 @@ # 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.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -33,6 +35,7 @@ class Budget(Document): from erpnext.accounts.doctype.budget_account.budget_account import BudgetAccount from erpnext.accounts.doctype.budget_distribution.budget_distribution import BudgetDistribution + account: DF.Link accounts: DF.Table[BudgetAccount] action_if_accumulated_monthly_budget_exceeded: DF.Literal["", "Stop", "Warn", "Ignore"] action_if_accumulated_monthly_budget_exceeded_on_mr: DF.Literal["", "Stop", "Warn", "Ignore"] @@ -42,6 +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"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -49,8 +53,11 @@ class Budget(Document): applicable_on_purchase_order: DF.Check budget_against: DF.Literal["", "Cost Center", "Project"] budget_distribution: DF.Table[BudgetDistribution] + budget_end_date: DF.Date + budget_start_date: DF.Date company: DF.Link cost_center: DF.Link | None + distribution_type: DF.Literal["Amount", "Percent"] fiscal_year: DF.Link monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] From d8deb33c8c3224b04134825794411c928f93e94b Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 02:13:41 +0530 Subject: [PATCH 06/28] feat: auto-generate budget distribution rows based on start and end date --- erpnext/accounts/doctype/budget/budget.py | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 0c4cb18087d..7df2187e8ef 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -8,6 +8,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.utils import add_months, flt, fmt_money, get_last_day, getdate, month_diff +from frappe.utils.data import get_first_day from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -153,6 +154,63 @@ class Budget(Document): def set_total_budget_amount(self): self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) + def before_save(self): + self.allocate_budget() + + def allocate_budget(self): + self.set("budget_distribution", []) + if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): + return + + start = getdate(self.budget_start_date) + end = getdate(self.budget_end_date) + freq = self.allocation_frequency + + current = start + + if freq == "Monthly": + while current <= end: + row = self.append("budget_distribution", {}) + row.start_date = get_first_day(current) + row.end_date = get_last_day(current) + current = add_months(current, 1) + + elif freq == "Quarterly": + while current <= end: + row = self.append("budget_distribution", {}) + + month = ((current.month - 1) // 3) * 3 + 1 + quarter_start = date(current.year, month, 1) + quarter_end = get_last_day(add_months(quarter_start, 2)) + if quarter_end > end: + quarter_end = end + row.start_date = quarter_start + row.end_date = quarter_end + current = add_months(quarter_start, 3) + + elif freq == "Half-Yearly": + while current <= end: + row = self.append("budget_distribution", {}) + 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) + if half_end > end: + half_end = end + row.start_date = half_start + row.end_date = half_end + current = add_months(half_start, 6) + + elif freq == "Yearly": + while current <= end: + row = self.append("budget_distribution", {}) + year_start = date(current.year, 1, 1) + year_end = date(current.year, 12, 31) + if year_end > end: + year_end = end + row.start_date = year_start + row.end_date = year_end + current = date(current.year + 1, 1, 1) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 882b6c29500422fb1fd54747dc86bd5c6f106f1a Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 15 Oct 2025 14:43:48 +0530 Subject: [PATCH 07/28] feat: add budget amount field on parent --- erpnext/accounts/doctype/budget/budget.json | 10 ++- erpnext/accounts/doctype/budget/budget.py | 68 +++++++++++++-------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 129ecd31be8..01953359c37 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -12,15 +12,16 @@ "company", "cost_center", "project", + "account", "fiscal_year", "column_break_3", "monthly_distribution", "amended_from", - "account", "distribution_type", "allocation_frequency", "budget_start_date", "budget_end_date", + "budget_amount", "section_break_6", "applicable_on_material_request", "action_if_annual_budget_exceeded_on_mr", @@ -307,13 +308,18 @@ "label": "Allocation Frequency", "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly\nDate Range", "reqd": 1 + }, + { + "fieldname": "budget_amount", + "fieldtype": "Currency", + "label": "Budget Amount" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 01:20:30.551362", + "modified": "2025-10-15 02:29:23.201493", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 7df2187e8ef..df9ec661423 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -53,6 +53,7 @@ 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 budget_start_date: DF.Date @@ -166,51 +167,68 @@ class Budget(Document): end = getdate(self.budget_end_date) freq = self.allocation_frequency + months = month_diff(end, start) + 1 + 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 + + 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 - if freq == "Monthly": - while current <= end: - row = self.append("budget_distribution", {}) + while current <= end: + row = self.append("budget_distribution", {}) + + 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": - while current <= end: - row = self.append("budget_distribution", {}) - + 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)) - if quarter_end > end: - quarter_end = end row.start_date = quarter_start - row.end_date = quarter_end + row.end_date = min(quarter_end, end) current = add_months(quarter_start, 3) - - elif freq == "Half-Yearly": - while current <= end: - row = self.append("budget_distribution", {}) + 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) - if half_end > end: - half_end = end row.start_date = half_start - row.end_date = half_end + row.end_date = min(half_end, end) current = add_months(half_start, 6) - - elif freq == "Yearly": - while current <= end: - row = self.append("budget_distribution", {}) + else: # Yearly year_start = date(current.year, 1, 1) year_end = date(current.year, 12, 31) - if year_end > end: - year_end = end row.start_date = year_start - row.end_date = year_end + row.end_date = min(year_end, end) current = date(current.year + 1, 1, 1) + if self.distribution_type == "Amount": + if len(self.budget_distribution) == total_periods: + row.amount = flt(self.budget_amount - assigned) + + 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) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) From 077692b57be26d73fb5e1e53896e69f1c4165f34 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 24 Oct 2025 23:35:11 +0530 Subject: [PATCH 08/28] feat: Budget Revision --- erpnext/accounts/doctype/budget/budget.js | 33 +++++++++++++++++++ erpnext/accounts/doctype/budget/budget.json | 16 +++++++-- erpnext/accounts/doctype/budget/budget.py | 23 ++++++++++++- .../budget_distribution.json | 8 +++-- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index d3931dec3db..1186b60ab11 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -32,6 +32,16 @@ frappe.ui.form.on("Budget", { refresh: function (frm) { frm.trigger("toggle_reqd_fields"); + + if (!frm.doc.__islocal && frm.doc.docstatus == 1) { + frm.add_custom_button( + __("Revise Budget"), + function () { + frm.events.revise_budget_action(frm); + }, + __("Actions") + ); + } }, budget_against: function (frm) { @@ -51,4 +61,27 @@ 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")); + } + ); + }, }); diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 01953359c37..fdf5b74c477 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -45,7 +45,9 @@ "column_break_ybiq", "total_budget_amount", "section_break_fpdt", - "budget_distribution" + "budget_distribution", + "section_break_kkan", + "revision_of" ], "fields": [ { @@ -313,13 +315,23 @@ "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount" + }, + { + "fieldname": "section_break_kkan", + "fieldtype": "Section Break" + }, + { + "fieldname": "revision_of", + "fieldtype": "Data", + "label": "Revision Of", + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 02:29:23.201493", + "modified": "2025-10-15 16:55:25.157976", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index df9ec661423..ed0348c7480 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -64,6 +64,7 @@ class Budget(Document): monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None + revision_of: DF.Data | None total_budget_amount: DF.Currency # end: auto-generated types @@ -159,6 +160,9 @@ class Budget(Document): self.allocate_budget() def allocate_budget(self): + if self.revision_of: + return + self.set("budget_distribution", []) if not (self.budget_start_date and self.budget_end_date and self.allocation_frequency): return @@ -167,7 +171,7 @@ class Budget(Document): end = getdate(self.budget_end_date) freq = self.allocation_frequency - months = month_diff(end, start) + 1 + months = month_diff(end, start) if freq == "Monthly": total_periods = months elif freq == "Quarterly": @@ -652,3 +656,20 @@ def get_expense_cost_center(doctype, args): return frappe.db.get_value( doctype, args.get(frappe.scrub(doctype)), ["cost_center", "default_expense_account"] ) + + +@frappe.whitelist() +def revise_budget(budget_name): + old_budget = frappe.get_doc("Budget", budget_name) + + if old_budget.docstatus == 1: + old_budget.cancel() + frappe.db.commit() + + 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_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json index 5c633fa392b..7602956e0c6 100644 --- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -29,20 +29,22 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount" + "label": "Amount", + "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" }, { "fieldname": "percent", "fieldtype": "Percent", "in_list_view": 1, - "label": "Percent" + "label": "Percent", + "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-12 23:47:30.393908", + "modified": "2025-10-15 16:53:23.462653", "modified_by": "Administrator", "module": "Accounts", "name": "Budget Distribution", From af9dc8e4063ac59437aea17082e17091b2436524 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Sat, 25 Oct 2025 01:01:05 +0530 Subject: [PATCH 09/28] test: budget revision test cases --- .../accounts/doctype/budget/test_budget.py | 117 +++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index ccc92fb518b..1afde6ed98e 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -3,7 +3,7 @@ import unittest import frappe -from frappe.utils import now_datetime, nowdate +from frappe.utils import add_days, getdate, now_datetime, nowdate from erpnext.accounts.doctype.budget.budget import ( BudgetError, @@ -25,6 +25,10 @@ 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") @@ -422,6 +426,117 @@ class TestBudget(ERPNextTestSuite): po.cancel() jv.cancel() + 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.append("accounts", {"account": self.account, "budget_amount": 100000}) + + start = getdate("2025-04-10") + end = getdate("2025-04-05") + + budget.append( + "budget_distribution", + { + "start_date": start, + "end_date": end, + "amount": 50000, + }, + ) + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_total_distribution_equals_budget(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 = ("_Test Account Cost for Goods Sold - _TC",) + budget.budget_amount = 12000 + + budget.start_date = getdate("2025-04-01") + budget.end_date = getdate("2025-06-30") + + budget.append( + "budget_distribution", + { + "start_date": getdate("2025-04-01"), + "end_date": getdate("2025-04-30"), + "amount": 6000, + }, + ) + budget.append( + "budget_distribution", + { + "start_date": getdate("2025-05-01"), + "end_date": getdate("2025-06-30"), + "amount": 5000, + }, + ) + + with self.assertRaises(frappe.ValidationError): + budget.save() + + def test_evenly_distribute_budget(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.distribute_evenly = 1 + budget.account = ("_Test Account Cost for Goods Sold - _TC",) + budget.budget_amount = 12000 + + budget.budget_start_date = getdate("2025-04-01") + budget.budget_end_date = getdate("2026-03-31") + + for i in range(12): + budget.append( + "budget_distribution", + { + "start_date": add_days(getdate("2025-04-01"), 30 * i), + "end_date": add_days(getdate("2025-04-30"), 30 * i), + }, + ) + + budget.save() + budget.reload() + + total = sum([d.amount for d in budget.budget_distribution]) + self.assertEqual(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) + + revised_name = frappe.get_doc("Budget", budget.name).revise_budget() + + 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.accounts[0].budget_amount, budget.accounts[0].budget_amount) + + old_budget = frappe.get_doc("Budget", budget.name) + self.assertEqual(old_budget.docstatus, 2) + + def test_revision_preserves_distribution(self): + budget = make_budget(budget_against="Cost Center", budget_amount=120000) + budget.distribute_evenly = 1 + budget.allocate_budget() + budget.save() + + revised_name = budget.revise_budget() + 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.accounts[0].budget_amount) + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project": From 64456af654e57494646c707116536ccdac4d25e0 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 28 Oct 2025 12:41:05 +0530 Subject: [PATCH 10/28] refactor: update budget expense validation to align with new structure --- erpnext/accounts/doctype/budget/budget.json | 32 +---- erpnext/accounts/doctype/budget/budget.py | 142 ++++++++------------ erpnext/controllers/budget_controller.py | 4 +- 3 files changed, 60 insertions(+), 118 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index fdf5b74c477..8f03782f0c6 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -15,7 +15,6 @@ "account", "fiscal_year", "column_break_3", - "monthly_distribution", "amended_from", "distribution_type", "allocation_frequency", @@ -40,10 +39,6 @@ "action_if_accumulated_monthly_exceeded_on_cumulative_expense", "section_break_21", "accounts", - "section_break_hqka", - "column_break_gnot", - "column_break_ybiq", - "total_budget_amount", "section_break_fpdt", "budget_distribution", "section_break_kkan", @@ -99,13 +94,6 @@ "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", @@ -248,24 +236,6 @@ "label": "Action if Accumulative Monthly Budget Exceeded on Cumulative Expense", "options": "\nStop\nWarn\nIgnore" }, - { - "fieldname": "section_break_hqka", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_gnot", - "fieldtype": "Column Break" - }, - { - "fieldname": "column_break_ybiq", - "fieldtype": "Column Break" - }, - { - "fieldname": "total_budget_amount", - "fieldtype": "Currency", - "label": "Total Budget Amount", - "read_only": 1 - }, { "fieldname": "section_break_fpdt", "fieldtype": "Section Break" @@ -331,7 +301,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-15 16:55:25.157976", + "modified": "2025-10-26 01:09:56.367821", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ed0348c7480..d2053d46d0b 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -7,6 +7,7 @@ from datetime import date import frappe 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 @@ -33,11 +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 account: DF.Link - accounts: DF.Table[BudgetAccount] 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"] @@ -61,73 +60,67 @@ class Budget(Document): cost_center: DF.Link | None distribution_type: DF.Literal["Amount", "Percent"] fiscal_year: DF.Link - monthly_distribution: DF.Link | None naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None revision_of: DF.Data | None - total_budget_amount: DF.Currency # 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_duplicate() - self.validate_accounts() + self.validate_account() self.set_null_value() self.validate_applicable_for() - self.set_total_budget_amount() def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) + account = self.account - 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, + 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"], ) - 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}'").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": @@ -153,9 +146,6 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 - def set_total_budget_amount(self): - self.total_budget_amount = flt(sum(d.budget_amount for d in self.accounts)) - def before_save(self): self.allocate_budget() @@ -295,7 +285,7 @@ def validate_expense_against_budget(args, expense_amount=0): budget_records = frappe.db.sql( f""" select - b.{budget_against} as budget_against, ba.budget_amount, b.monthly_distribution, + b.name, b.{budget_against} as budget_against, b.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, @@ -303,10 +293,10 @@ def validate_expense_against_budget(args, expense_amount=0): 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 + `tabBudget` b where - b.name=ba.parent and b.fiscal_year=%s - and ba.account=%s and b.docstatus=1 + b.fiscal_year=%s + and b.account=%s and b.docstatus=1 {condition} """, (args.fiscal_year, args.account), @@ -335,9 +325,7 @@ 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, args.posting_date) args["month_end_date"] = get_last_day(args.posting_date) @@ -581,37 +569,23 @@ def get_actual_expense(args): 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.end_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): diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 6a9e6ae316d..0325f18b972 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, ) } ) From 1f832ca23ede1864dd5a495b2d7e73a011b4d748 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 29 Oct 2025 02:53:37 +0530 Subject: [PATCH 11/28] fix: test cases of budget --- erpnext/accounts/doctype/budget/budget.json | 16 +-- erpnext/accounts/doctype/budget/budget.py | 2 +- .../accounts/doctype/budget/test_budget.py | 113 +++++++++--------- 3 files changed, 57 insertions(+), 74 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 8f03782f0c6..c98b27abbd5 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -37,8 +37,6 @@ "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_fpdt", "budget_distribution", "section_break_kkan", @@ -188,18 +186,6 @@ "label": "Action if Accumulated Monthly Budget Exceeded on Actual", "options": "\nStop\nWarn\nIgnore" }, - { - "fieldname": "section_break_21", - "fieldtype": "Section Break", - "hide_border": 1 - }, - { - "fieldname": "accounts", - "fieldtype": "Table", - "label": "Budget Accounts", - "options": "Budget Account", - "reqd": 1 - }, { "fieldname": "naming_series", "fieldtype": "Select", @@ -301,7 +287,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-26 01:09:56.367821", + "modified": "2025-10-28 13:02:43.456568", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index d2053d46d0b..58595e4f708 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -285,7 +285,7 @@ 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, b.monthly_distribution, + 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, diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 1afde6ed98e..e8402811950 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -9,6 +9,7 @@ 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 @@ -24,7 +25,7 @@ class TestBudget(ERPNextTestSuite): cls.make_projects() def setUp(self): - frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", False) + frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", True) self.company = "_Test Company" self.fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") self.account = "_Test Account Cost for Goods Sold - _TC" @@ -59,7 +60,8 @@ class TestBudget(ERPNextTestSuite): 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", @@ -81,9 +83,7 @@ class TestBudget(ERPNextTestSuite): 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", @@ -118,7 +118,8 @@ class TestBudget(ERPNextTestSuite): 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( @@ -162,7 +163,8 @@ class TestBudget(ERPNextTestSuite): 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 @@ -185,7 +187,8 @@ class TestBudget(ERPNextTestSuite): 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", @@ -306,7 +309,8 @@ class TestBudget(ERPNextTestSuite): 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", @@ -339,7 +343,8 @@ class TestBudget(ERPNextTestSuite): 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", @@ -393,9 +398,7 @@ class TestBudget(ERPNextTestSuite): 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 - ) + accumulated_limit = get_accumulated_monthly_budget(budget.name, nowdate()) jv = make_journal_entry( "_Test Account Cost for Goods Sold - _TC", @@ -414,6 +417,8 @@ class TestBudget(ERPNextTestSuite): ) po.set_missing_values() + print(">>>>>>>>>>>>>>>>>>>>>>>>") + self.assertRaises(BudgetError, po.submit) frappe.db.set_value( @@ -432,7 +437,8 @@ class TestBudget(ERPNextTestSuite): budget.fiscal_year = self.fiscal_year budget.budget_against = "Cost Center" budget.cost_center = self.cost_center - budget.append("accounts", {"account": self.account, "budget_amount": 100000}) + budget.account = self.account + budget.budget_amount = 100000 start = getdate("2025-04-10") end = getdate("2025-04-05") @@ -482,29 +488,7 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_evenly_distribute_budget(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.distribute_evenly = 1 - budget.account = ("_Test Account Cost for Goods Sold - _TC",) - budget.budget_amount = 12000 - - budget.budget_start_date = getdate("2025-04-01") - budget.budget_end_date = getdate("2026-03-31") - - for i in range(12): - budget.append( - "budget_distribution", - { - "start_date": add_days(getdate("2025-04-01"), 30 * i), - "end_date": add_days(getdate("2025-04-30"), 30 * i), - }, - ) - - budget.save() - budget.reload() + budget = make_budget(budget_against="Cost Center", budget_amount=120000) total = sum([d.amount for d in budget.budget_distribution]) self.assertEqual(total, 120000) @@ -513,29 +497,26 @@ class TestBudget(ERPNextTestSuite): def test_create_revised_budget(self): budget = make_budget(budget_against="Cost Center", budget_amount=120000) - revised_name = frappe.get_doc("Budget", budget.name).revise_budget() + 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.accounts[0].budget_amount, budget.accounts[0].budget_amount) + 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): budget = make_budget(budget_against="Cost Center", budget_amount=120000) - budget.distribute_evenly = 1 - budget.allocate_budget() - budget.save() - revised_name = budget.revise_budget() + 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.accounts[0].budget_amount) + self.assertEqual(total, revised_budget.budget_amount) def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): @@ -589,18 +570,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") @@ -609,18 +605,19 @@ 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.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.budget_start_date = "2025-04-01" + budget.budget_end_date = "2026-03-31" + budget.allocation_frequency = "Monthly" + budget.distribution_type = "Amount" if args.applicable_on_material_request: budget.applicable_on_material_request = 1 From b5d892c802357995441cee4924a9cf845adbf2f0 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 29 Oct 2025 13:00:29 +0530 Subject: [PATCH 12/28] fix: default company currency for amount --- erpnext/accounts/doctype/budget/budget.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index c98b27abbd5..643f4f78d8f 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -270,7 +270,9 @@ { "fieldname": "budget_amount", "fieldtype": "Currency", - "label": "Budget Amount" + "label": "Budget Amount", + "options": "Company:company:default_currency", + "reqd": 1 }, { "fieldname": "section_break_kkan", @@ -280,6 +282,7 @@ "fieldname": "revision_of", "fieldtype": "Data", "label": "Revision Of", + "no_copy": 1, "read_only": 1 } ], @@ -287,7 +290,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-28 13:02:43.456568", + "modified": "2025-10-29 03:06:52.730795", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From bd88356a8aba0a00f133011fac249b010e42ccd4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 30 Oct 2025 20:12:12 +0530 Subject: [PATCH 13/28] feat: budget for multiple fiscal year --- erpnext/accounts/doctype/budget/budget.json | 76 ++-- erpnext/accounts/doctype/budget/budget.py | 403 +++++++++++------- .../accounts/doctype/budget/test_budget.py | 121 ++++-- .../budget_distribution.json | 6 +- 4 files changed, 388 insertions(+), 218 deletions(-) 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", From 1cb03db43bfb5a55e67e9383a495e4dcb6242ef9 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 31 Oct 2025 00:56:25 +0530 Subject: [PATCH 14/28] test: test cases to validate budget distribution and revision --- erpnext/accounts/doctype/budget/budget.py | 10 ++-- .../accounts/doctype/budget/test_budget.py | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index e0eaff8b4a3..120d78b7430 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -81,11 +81,8 @@ class Budget(Document): if not account: return - 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"] + year_start_date, year_end_date = get_fiscal_year_date_range( + self.from_fiscal_year, self.to_fiscal_year ) existing_budget = frappe.db.sql( @@ -103,12 +100,13 @@ class Budget(Document): 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), + (self.company, budget_against, account, self.name, year_end_date, year_start_date), as_dict=True, ) if existing_budget: d = existing_budget[0] + print(d) frappe.throw( _( "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index d2723bc0db0..6f1e751993b 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -570,6 +570,62 @@ class TestBudget(ERPNextTestSuite): 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", + "company": "_Test Company 2", + } + ).insert(ignore_permissions=True) + + budget.from_fiscal_year = fy.name + budget.to_fiscal_year = fy.name + + 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): + make_budget( + budget_against="Cost Center", + distribute_equally=0, + budget_amount=15000, + do_not_save=False, + submit_budget=False, + ) + + budget = frappe.new_doc("Budget") + budget.company = "_Test Company" + budget.from_fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") + budget.to_fiscal_year = budget.from_fiscal_year + budget.budget_against = "Cost Center" + budget.cost_center = "_Test Cost Center - _TC" + budget.account = "_Test Account Cost for Goods Sold - _TC" + budget.budget_amount = 10000 + + with self.assertRaises(frappe.ValidationError): + budget.insert() + def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): if budget_against_field == "project": From e40fe9919cea709a377d679dba36bbb223238db7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 31 Oct 2025 13:43:07 +0530 Subject: [PATCH 15/28] refactor: better manual budget distribution ux --- erpnext/accounts/doctype/budget/budget.js | 26 +++++++++++++++++ erpnext/accounts/doctype/budget/budget.json | 4 +-- erpnext/accounts/doctype/budget/budget.py | 28 +++++++++++++++++-- .../accounts/doctype/budget/test_budget.py | 12 ++++---- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 1186b60ab11..4b2aefffb57 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -49,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); @@ -85,3 +94,20 @@ frappe.ui.form.on("Budget", { ); }, }); + +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 849d4a5e927..e88daf00565 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -231,6 +231,7 @@ { "fieldname": "account", "fieldtype": "Link", + "in_list_view": 1, "label": "Account", "options": "Account", "read_only_depends_on": "eval: doc.revision_of", @@ -258,7 +259,6 @@ "fieldname": "budget_amount", "fieldtype": "Currency", "label": "Budget Amount", - "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, { @@ -304,7 +304,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-30 19:07:51.022844", + "modified": "2025-10-31 01:13:15.114440", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 120d78b7430..b9a0909ef45 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -160,6 +160,9 @@ class Budget(Document): def before_save(self): self.allocate_budget() + def on_update(self): + self.validate_distribution_totals() + def allocate_budget(self): if self.revision_of: return @@ -183,14 +186,14 @@ class Budget(Document): 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 self.budget_distribution: + if not old_doc or not self.budget_distribution: return True if old_doc: changed_fields = [ "from_fiscal_year", "to_fiscal_year", + "budget_amount", "allocation_frequency", "distribute_equally", ] @@ -255,9 +258,28 @@ class Budget(Document): row.amount = 0 row.percent = 0 else: - row.amount = flt(self.budget_amount * row_percent / 100, 2) + 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 equal Budget Amount {1}").format( + flt(total_amount, 2), self.budget_amount + ) + ) + + if round(total_percent, 2) != 100: + frappe.throw( + _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2)) + ) + def validate_expense_against_budget(args, expense_amount=0): args = frappe._dict(args) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 6f1e751993b..1768d69349a 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -551,16 +551,16 @@ class TestBudget(ERPNextTestSuite): budget_amount=30000, budget_start_date="2025-04-01", budget_end_date="2025-06-30", - do_not_save=True, + 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}, - {"start_date": "2025-05-01", "end_date": "2025-05-31", "amount": 15000}, - {"start_date": "2025-06-01", "end_date": "2025-06-30", "amount": 5000}, + {"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) @@ -608,10 +608,10 @@ class TestBudget(ERPNextTestSuite): def test_duplicate_budget_validation(self): make_budget( budget_against="Cost Center", - distribute_equally=0, + distribute_equally=1, budget_amount=15000, do_not_save=False, - submit_budget=False, + submit_budget=True, ) budget = frappe.new_doc("Budget") From e4bae765807b6477e4ad05c8ec78d790122f1db6 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 5 Nov 2025 01:21:54 +0530 Subject: [PATCH 16/28] refactor: add budget start and end date field on the parent --- erpnext/accounts/doctype/budget/budget.json | 26 +++++++------ erpnext/accounts/doctype/budget/budget.py | 37 ++++++++++--------- .../accounts/doctype/budget/test_budget.py | 1 - .../budget_distribution.json | 8 ++-- 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index e88daf00565..9565f16e673 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -17,7 +17,8 @@ "amended_from", "from_fiscal_year", "to_fiscal_year", - "distribution_type", + "budget_start_date", + "budget_end_date", "allocation_frequency", "budget_amount", "section_break_nwug", @@ -237,15 +238,6 @@ "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, - { - "default": "Percent", - "fieldname": "distribution_type", - "fieldtype": "Select", - "label": "Distribution Type", - "options": "Amount\nPercent", - "read_only_depends_on": "eval: doc.revision_of", - "reqd": 1 - }, { "default": "Monthly", "fieldname": "allocation_frequency", @@ -298,13 +290,25 @@ "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" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-10-31 01:13:15.114440", + "modified": "2025-11-05 01:00:46.470251", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index b9a0909ef45..2eff6cbac02 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -54,10 +54,11 @@ class Budget(Document): 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 distribute_equally: DF.Check - distribution_type: DF.Literal["Amount", "Percent"] from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None @@ -68,11 +69,23 @@ class Budget(Document): def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) + self.set_fiscal_year_dates() self.validate_duplicate() self.validate_account() self.set_null_value() self.validate_applicable_for() + 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" + ) + def validate_duplicate(self): budget_against_field = frappe.scrub(self.budget_against) budget_against = self.get(budget_against_field) @@ -275,7 +288,7 @@ class Budget(Document): ) ) - if round(total_percent, 2) != 100: + if flt(abs(total_percent - 100), 2) > 0.10: frappe.throw( _("Total distribution percent must equal 100 (currently {0})").format(round(total_percent, 2)) ) @@ -361,6 +374,8 @@ def validate_expense_against_budget(args, expense_amount=0): 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, @@ -375,12 +390,7 @@ def validate_expense_against_budget(args, expense_amount=0): 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 %s BETWEEN b.budget_start_date AND b.budget_end_date AND b.account = %s {condition} """, @@ -627,14 +637,7 @@ 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}'" + date_condition = f"and gle.posting_date between '{args.budget_start_date}' and '{args.budget_end_date}'" if args.is_tree: lft_rgt = frappe.db.get_value( @@ -687,7 +690,7 @@ def get_accumulated_monthly_budget(budget_name, posting_date): .on(bd.parent == b.name) .select(Sum(bd.amount).as_("accumulated_amount")) .where(b.name == budget_name) - .where(bd.end_date >= posting_date) + .where(bd.start_date <= posting_date) .run(as_dict=True) ) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 1768d69349a..a6261e6188c 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -725,7 +725,6 @@ def make_budget(**args): budget.budget_against = budget_against budget.allocation_frequency = "Monthly" - budget.distribution_type = "Amount" budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: diff --git a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json index 1a367010c98..85d14599cec 100644 --- a/erpnext/accounts/doctype/budget_distribution/budget_distribution.json +++ b/erpnext/accounts/doctype/budget_distribution/budget_distribution.json @@ -31,22 +31,20 @@ "fieldname": "amount", "fieldtype": "Currency", "in_list_view": 1, - "label": "Amount", - "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" + "label": "Amount" }, { "fieldname": "percent", "fieldtype": "Percent", "in_list_view": 1, - "label": "Percent", - "read_only_depends_on": "eval:doc.end_date < frappe.datetime.get_today()" + "label": "Percent" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-30 12:35:31.310931", + "modified": "2025-11-03 13:18:28.398198", "modified_by": "Administrator", "module": "Accounts", "name": "Budget Distribution", From 04a44e7e14c67e00912fed8da8e1d704bd9bbee7 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 5 Nov 2025 16:07:26 +0530 Subject: [PATCH 17/28] refactor: budget controller --- erpnext/accounts/doctype/budget/budget.py | 4 ++++ erpnext/controllers/budget_controller.py | 23 ++++++++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 2eff6cbac02..69c1e1eb8f3 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -409,6 +409,10 @@ def validate_budget_records(args, budget_records, expense_amount): 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 + args["budget_start_date"], args["budget_end_date"] = ( + budget.budget_start_date, + budget.budget_end_date, + ) if yearly_action in ("Stop", "Warn"): compare_expense_with_budget( diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 0325f18b972..33afac4df22 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -162,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, @@ -184,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")]) @@ -312,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) From 09ed3066d8d2b581b03270c8daee0c3c0b0695ae Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 6 Nov 2025 03:15:10 +0530 Subject: [PATCH 18/28] fix: test cases and fiscal year validation --- erpnext/accounts/doctype/budget/budget.py | 42 +++++++++---------- .../accounts/doctype/budget/test_budget.py | 28 ++++++------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 69c1e1eb8f3..ffbdb3c0955 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -69,12 +69,26 @@ class Budget(Document): def validate(self): if not self.get(frappe.scrub(self.budget_against)): frappe.throw(_("{0} is mandatory").format(self.budget_against)) + self.validate_fiscal_year() self.set_fiscal_year_dates() self.validate_duplicate() self.validate_account() self.set_null_value() self.validate_applicable_for() + 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( @@ -94,10 +108,6 @@ class Budget(Document): if not account: return - year_start_date, year_end_date = get_fiscal_year_date_range( - self.from_fiscal_year, self.to_fiscal_year - ) - existing_budget = frappe.db.sql( f""" SELECT name, account @@ -113,13 +123,12 @@ class Budget(Document): AND (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s ) """, - (self.company, budget_against, account, self.name, year_end_date, year_start_date), + (self.company, budget_against, account, self.name, self.budget_end_date, self.budget_start_date), as_dict=True, ) if existing_budget: d = existing_budget[0] - print(d) frappe.throw( _( "Another Budget record '{0}' already exists against {1} '{2}' and account '{3}' with overlapping fiscal years." @@ -185,7 +194,6 @@ class Budget(Document): self.set("budget_distribution", []) - self.set_budget_date_range() periods = self.get_budget_periods() total_periods = len(periods) row_percent = 100 / total_periods if total_periods else 0 @@ -216,18 +224,6 @@ class Budget(Document): 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 @@ -299,6 +295,9 @@ 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) @@ -522,7 +521,7 @@ def get_expense_breakup(args, currency, budget_against): "status": [["!=", "Stopped"]], "docstatus": 1, "material_request_type": "Purchase", - "schedule_date": [["between", from_date, to_date]], + "schedule_date": [["between", [from_date, to_date]]], "item_code": args.item_code, "per_ordered": [["<", 100]], } @@ -547,7 +546,7 @@ def get_expense_breakup(args, currency, budget_against): { "status": [["!=", "Closed"]], "docstatus": 1, - "transaction_date": [["between", from_date, to_date]], + "transaction_date": [["between", [from_date, to_date]]], "item_code": args.item_code, "per_billed": [["<", 100]], } @@ -761,7 +760,6 @@ def revise_budget(budget_name): if old_budget.docstatus == 1: old_budget.cancel() - frappe.db.commit() new_budget = frappe.copy_doc(old_budget) new_budget.docstatus = 0 diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index a6261e6188c..537942e7c8e 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -26,7 +26,7 @@ class TestBudget(ERPNextTestSuite): cls.make_projects() def setUp(self): - frappe.db.set_single_value("Accounts Settings", "use_legacy_budget_controller", True) + 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" @@ -113,7 +113,7 @@ class TestBudget(ERPNextTestSuite): action_if_accumulated_monthly_budget_exceeded_on_mr="Stop", budget_against="Cost Center", do_not_save=False, - subimit_budget=True, + submit_budget=True, ) frappe.db.set_value("Budget", budget.name, "action_if_accumulated_monthly_budget_exceeded", "Stop") @@ -122,7 +122,6 @@ class TestBudget(ERPNextTestSuite): budget.name, nowdate(), ) - mr = frappe.get_doc( { "doctype": "Material Request", @@ -579,12 +578,13 @@ class TestBudget(ERPNextTestSuite): "year": "2099", "year_start_date": "2099-04-01", "year_end_date": "2100-03-31", - "company": "_Test Company 2", + "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() @@ -606,7 +606,7 @@ class TestBudget(ERPNextTestSuite): budget.save() def test_duplicate_budget_validation(self): - make_budget( + budget = make_budget( budget_against="Cost Center", distribute_equally=1, budget_amount=15000, @@ -614,17 +614,17 @@ class TestBudget(ERPNextTestSuite): submit_budget=True, ) - budget = frappe.new_doc("Budget") - budget.company = "_Test Company" - budget.from_fiscal_year = frappe.db.get_value("Fiscal Year", {}, "name") - budget.to_fiscal_year = budget.from_fiscal_year - budget.budget_against = "Cost Center" - budget.cost_center = "_Test Cost Center - _TC" - budget.account = "_Test Account Cost for Goods Sold - _TC" - budget.budget_amount = 10000 + 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): - budget.insert() + new_budget.insert() def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None): From 57f9faa15af93c80acab7ea43b9d960a660964e4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 6 Nov 2025 14:03:09 +0530 Subject: [PATCH 19/28] fix: validate existing expenses when revising or modifying budget amounts --- erpnext/accounts/doctype/budget/budget.json | 22 ++--- erpnext/accounts/doctype/budget/budget.py | 54 +++++++++++-- .../accounts/doctype/budget/test_budget.py | 81 ++++++++----------- 3 files changed, 95 insertions(+), 62 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index 9565f16e673..a17919c52b0 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -19,7 +19,7 @@ "to_fiscal_year", "budget_start_date", "budget_end_date", - "allocation_frequency", + "distribution_frequency", "budget_amount", "section_break_nwug", "distribute_equally", @@ -238,15 +238,6 @@ "read_only_depends_on": "eval: doc.revision_of", "reqd": 1 }, - { - "default": "Monthly", - "fieldname": "allocation_frequency", - "fieldtype": "Select", - "label": "Allocation Frequency", - "options": "Monthly\nQuarterly\nHalf-Yearly\nYearly", - "read_only_depends_on": "eval: doc.revision_of", - "reqd": 1 - }, { "fieldname": "budget_amount", "fieldtype": "Currency", @@ -302,13 +293,22 @@ "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-11-05 01:00:46.470251", + "modified": "2025-11-06 10:36:35.565701", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ffbdb3c0955..5bbf8dfc0bd 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -45,7 +45,6 @@ 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"] amended_from: DF.Link | None applicable_on_booking_actual_expenses: DF.Check applicable_on_cumulative_expense: DF.Check @@ -59,6 +58,7 @@ class Budget(Document): company: DF.Link cost_center: DF.Link | None distribute_equally: DF.Check + distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] from_fiscal_year: DF.Link naming_series: DF.Literal["BUDGET-.YYYY.-"] project: DF.Link | None @@ -75,6 +75,7 @@ class Budget(Document): self.validate_account() self.set_null_value() self.validate_applicable_for() + self.validate_existing_expenses() def validate_fiscal_year(self): if self.from_fiscal_year: @@ -94,12 +95,14 @@ class Budget(Document): 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) @@ -179,6 +182,47 @@ class Budget(Document): ): self.applicable_on_booking_actual_expenses = 1 + def validate_existing_expenses(self): + if self.is_new() and self.revision_of: + return + + args = 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), + } + ) + + args[args.budget_against_field] = self.get(args.budget_against_field) + + if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): + args.is_tree = True + else: + args.is_tree = False + + actual_spent = get_actual_expense(args) + + 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() @@ -215,7 +259,7 @@ class Budget(Document): "from_fiscal_year", "to_fiscal_year", "budget_amount", - "allocation_frequency", + "distribution_frequency", "distribute_equally", ] for field in changed_fields: @@ -226,7 +270,7 @@ class Budget(Document): def get_budget_periods(self): """Return list of (start_date, end_date) tuples based on frequency.""" - frequency = self.allocation_frequency + frequency = self.distribution_frequency periods = [] start_date = getdate(self.budget_start_date) @@ -279,7 +323,7 @@ class Budget(Document): if flt(abs(total_amount - self.budget_amount), 2) > 0.10: frappe.throw( - _("Total distributed amount {0} must equal Budget Amount {1}").format( + _("Total distributed amount {0} must be equal to Budget Amount {1}").format( flt(total_amount, 2), self.budget_amount ) ) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index 537942e7c8e..dd31cdc636f 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -446,24 +446,23 @@ class TestBudget(ERPNextTestSuite): po.cancel() jv.cancel() - def test_distribution_date_validation(self): - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = self.account - budget.budget_amount = 100000 - - start = getdate("2025-04-10") - end = getdate("2025-04-05") - - budget.append( - "budget_distribution", + def test_fiscal_year_validation(self): + frappe.get_doc( { - "start_date": start, - "end_date": end, - "amount": 50000, - }, + "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): @@ -473,35 +472,14 @@ class TestBudget(ERPNextTestSuite): budget = make_budget( budget_against="Cost Center", applicable_on_cumulative_expense=True, + distribute_equally=0, + budget_amount=12000, do_not_save=False, - submit_budget=True, + submit_budget=False, ) - budget = frappe.new_doc("Budget") - budget.company = self.company - budget.budget_against = "Cost Center" - budget.cost_center = self.cost_center - budget.account = ("_Test Account Cost for Goods Sold - _TC",) - budget.budget_amount = 12000 - budget.start_date = getdate("2025-04-01") - budget.end_date = getdate("2025-06-30") - - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-04-01"), - "end_date": getdate("2025-04-30"), - "amount": 6000, - }, - ) - budget.append( - "budget_distribution", - { - "start_date": getdate("2025-05-01"), - "end_date": getdate("2025-06-30"), - "amount": 5000, - }, - ) + for row in budget.budget_distribution: + row.amount = 2000 with self.assertRaises(frappe.ValidationError): budget.save() @@ -531,6 +509,7 @@ class TestBudget(ERPNextTestSuite): 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 ) @@ -634,6 +613,7 @@ 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( { @@ -644,12 +624,21 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again "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: @@ -714,8 +703,8 @@ def make_budget(**args): else: budget.cost_center = cost_center or "_Test Cost Center - _TC" - budget.from_fiscal_year = fiscal_year - budget.to_fiscal_year = fiscal_year + 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 @@ -724,7 +713,7 @@ def make_budget(**args): budget.action_if_accumulated_monthly_budget_exceeded = "Ignore" budget.budget_against = budget_against - budget.allocation_frequency = "Monthly" + budget.distribution_frequency = "Monthly" budget.distribute_equally = args.get("distribute_equally", 1) if args.applicable_on_material_request: From 4abe2e82a066326573644d0f641f9f1c400e38c4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 14:35:27 +0530 Subject: [PATCH 20/28] fix(patch): migrate old Budget data to new structure --- erpnext/patches.txt | 2 +- ...rate_submitted_budgets_to_new_structure.py | 102 ++++++++++++++++++ .../patches/v16_0/set_total_budget_amount.py | 16 --- 3 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py delete mode 100644 erpnext/patches/v16_0/set_total_budget_amount.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index a894f8cb44a..7d3296fe327 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.set_total_budget_amount +erpnext.patches.v16_0.migrate_submitted_budgets_to_new_structure diff --git a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py b/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py new file mode 100644 index 00000000000..d803a852d3f --- /dev/null +++ b/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py @@ -0,0 +1,102 @@ +import frappe +from frappe.utils import add_months, flt, get_first_day, get_last_day + + +def execute(): + submitted_budgets = frappe.get_all("Budget", filters={"docstatus": 1}, pluck="name") + + for old_budget in submitted_budgets: + old_bud = frappe.get_doc("Budget", old_budget) + + old_accounts = frappe.get_all( + "Budget Account", + filters={"parent": old_bud.name}, + fields=["account", "budget_amount"], + order_by="idx asc", + ) + + if not old_accounts: + continue + + old_distribution = [] + if old_bud.monthly_distribution: + old_distribution = frappe.get_all( + "Monthly Distribution Percentage", + filters={"parent": old_bud.monthly_distribution}, + fields=["percentage_allocation"], + order_by="idx asc", + ) + + if old_distribution: + percentage_list = [flt(d.percentage) for d in old_distribution] + else: + percentage_list = [100 / 12] * 12 + + fy = frappe.get_doc("Fiscal Year", old_bud.fiscal_year) + fy_start = fy.year_start_date + fy_end = fy.year_end_date + + for acc in old_accounts: + new = frappe.new_doc("Budget") + + new.company = old_bud.company + new.cost_center = old_bud.cost_center + new.project = old_bud.project + new.fiscal_year = fy.name + + new.from_fiscal_year = fy.name + new.to_fiscal_year = fy.name + new.budget_start_date = fy_start + new.budget_end_date = fy_end + + new.account = acc.account + new.budget_amount = flt(acc.budget_amount) + new.distribution_frequency = "Monthly" + + new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 + + fields_to_copy = [ + "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 fields_to_copy: + if hasattr(old_bud, field): + new.set(field, old_bud.get(field)) + + start = fy_start + for percentage in percentage_list: + row_start = get_first_day(start) + row_end = get_last_day(start) + + new.append( + "budget_distribution", + { + "start_date": row_start, + "end_date": row_end, + "percent": percentage, + "amount": new.budget_amount * percentage / 100, + }, + ) + + start = add_months(start, 1) + + new.flags.ignore_validate = True + new.flags.ignore_mandatory = True + new.flags.ignore_links = True + new.flags.ignore_permissions = True + + new.insert(ignore_permissions=True, ignore_mandatory=True) + new.submit() + + old_bud.cancel() diff --git a/erpnext/patches/v16_0/set_total_budget_amount.py b/erpnext/patches/v16_0/set_total_budget_amount.py deleted file mode 100644 index 163f0f17274..00000000000 --- a/erpnext/patches/v16_0/set_total_budget_amount.py +++ /dev/null @@ -1,16 +0,0 @@ -import frappe - - -def execute(): - if frappe.db.has_column("Budget", "total_budget_amount"): - frappe.db.sql( - """ - UPDATE `tabBudget` b - SET b.total_budget_amount = ( - SELECT SUM(ba.budget_amount) - FROM `tabBudget Account` ba - WHERE ba.parent = b.name - ) - WHERE IFNULL(b.total_budget_amount, 0) = 0 - """ - ) From e08793cb8fd82e5be35ec7d921d8fa4ad4c1673b Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 17:43:41 +0530 Subject: [PATCH 21/28] fix(patch): update naming series for budget --- erpnext/accounts/doctype/budget/budget.json | 4 +-- erpnext/accounts/doctype/budget/budget.py | 2 +- erpnext/patches.txt | 2 +- ...igrate_budget_records_to_new_structure.py} | 35 ++++++++++--------- 4 files changed, 23 insertions(+), 20 deletions(-) rename erpnext/patches/v16_0/{migrate_submitted_budgets_to_new_structure.py => migrate_budget_records_to_new_structure.py} (76%) diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index a17919c52b0..bf1e7d091c1 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -188,7 +188,7 @@ "fieldtype": "Select", "label": "Series", "no_copy": 1, - "options": "BUDGET-.YYYY.-", + "options": "BUDGET-.########", "print_hide": 1, "reqd": 1, "set_only_once": 1 @@ -308,7 +308,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-06 10:36:35.565701", + "modified": "2025-11-17 17:38:27.759355", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 5bbf8dfc0bd..ad16cf00a9a 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -60,7 +60,7 @@ class Budget(Document): distribute_equally: DF.Check distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"] from_fiscal_year: DF.Link - naming_series: DF.Literal["BUDGET-.YYYY.-"] + naming_series: DF.Literal["BUDGET-.########"] project: DF.Link | None revision_of: DF.Data | None to_fiscal_year: DF.Link diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 7d3296fe327..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_submitted_budgets_to_new_structure +erpnext.patches.v16_0.migrate_budget_records_to_new_structure diff --git a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py similarity index 76% rename from erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py rename to erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index d803a852d3f..1c94f0e34bd 100644 --- a/erpnext/patches/v16_0/migrate_submitted_budgets_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -3,14 +3,14 @@ from frappe.utils import add_months, flt, get_first_day, get_last_day def execute(): - submitted_budgets = frappe.get_all("Budget", filters={"docstatus": 1}, pluck="name") + budgets = frappe.get_all("Budget", filters={"docstatus": ["in", [0, 1]]}, pluck="name") - for old_budget in submitted_budgets: - old_bud = frappe.get_doc("Budget", old_budget) + for budget in budgets: + old_budget = frappe.get_doc("Budget", budget) old_accounts = frappe.get_all( "Budget Account", - filters={"parent": old_bud.name}, + filters={"parent": old_budget.name}, fields=["account", "budget_amount"], order_by="idx asc", ) @@ -19,10 +19,10 @@ def execute(): continue old_distribution = [] - if old_bud.monthly_distribution: + if old_budget.monthly_distribution: old_distribution = frappe.get_all( "Monthly Distribution Percentage", - filters={"parent": old_bud.monthly_distribution}, + filters={"parent": old_budget.monthly_distribution}, fields=["percentage_allocation"], order_by="idx asc", ) @@ -32,16 +32,16 @@ def execute(): else: percentage_list = [100 / 12] * 12 - fy = frappe.get_doc("Fiscal Year", old_bud.fiscal_year) + fy = frappe.get_doc("Fiscal Year", old_budget.fiscal_year) fy_start = fy.year_start_date fy_end = fy.year_end_date for acc in old_accounts: new = frappe.new_doc("Budget") - new.company = old_bud.company - new.cost_center = old_bud.cost_center - new.project = old_bud.project + new.company = old_budget.company + new.cost_center = old_budget.cost_center + new.project = old_budget.project new.fiscal_year = fy.name new.from_fiscal_year = fy.name @@ -71,8 +71,8 @@ def execute(): ] for field in fields_to_copy: - if hasattr(old_bud, field): - new.set(field, old_bud.get(field)) + if hasattr(old_budget, field): + new.set(field, old_budget.get(field)) start = fy_start for percentage in percentage_list: @@ -92,11 +92,14 @@ def execute(): start = add_months(start, 1) new.flags.ignore_validate = True - new.flags.ignore_mandatory = True new.flags.ignore_links = True - new.flags.ignore_permissions = True new.insert(ignore_permissions=True, ignore_mandatory=True) - new.submit() - old_bud.cancel() + if old_budget.docstatus == 1: + new.submit() + + if old_budget.docstatus == 1: + old_budget.cancel() + else: + old_budget.delete() From 22ec48159e1ef396e370abf6c517a005c92eff8a Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 17 Nov 2025 17:59:20 +0530 Subject: [PATCH 22/28] fix(minor): use corrct field name in patch --- .../patches/v16_0/migrate_budget_records_to_new_structure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1c94f0e34bd..fd6920cd99e 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -28,7 +28,7 @@ def execute(): ) if old_distribution: - percentage_list = [flt(d.percentage) for d in old_distribution] + percentage_list = [flt(d.percentage_allocation) for d in old_distribution] else: percentage_list = [100 / 12] * 12 From 4576ccbbdc29c79d504149171782fe4db184c9fc Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 18 Nov 2025 11:31:52 +0530 Subject: [PATCH 23/28] fix: use new naming series --- .../v16_0/migrate_budget_records_to_new_structure.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 index fd6920cd99e..1051184c4a2 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -8,14 +8,14 @@ def execute(): for budget in budgets: old_budget = frappe.get_doc("Budget", budget) - old_accounts = frappe.get_all( + accounts = frappe.get_all( "Budget Account", filters={"parent": old_budget.name}, fields=["account", "budget_amount"], order_by="idx asc", ) - if not old_accounts: + if not accounts: continue old_distribution = [] @@ -36,9 +36,11 @@ def execute(): fy_start = fy.year_start_date fy_end = fy.year_end_date - for acc in old_accounts: + for account in accounts: new = frappe.new_doc("Budget") + new.naming_series = "BUDGET-.########" + new.budget_against = old_budget.budget_against new.company = old_budget.company new.cost_center = old_budget.cost_center new.project = old_budget.project @@ -49,8 +51,8 @@ def execute(): new.budget_start_date = fy_start new.budget_end_date = fy_end - new.account = acc.account - new.budget_amount = flt(acc.budget_amount) + new.account = account.account + new.budget_amount = flt(account.budget_amount) new.distribution_frequency = "Monthly" new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 From 4a03462890ac8fce7e558f1aa51f59c43d4a3e0e Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 18 Nov 2025 11:46:45 +0530 Subject: [PATCH 24/28] refactor: use params instead of args --- erpnext/accounts/doctype/budget/budget.py | 126 +++++++++++----------- 1 file changed, 66 insertions(+), 60 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index ad16cf00a9a..5a0f2b0dde4 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -334,16 +334,16 @@ class Budget(Document): ) -def validate_expense_against_budget(args, expense_amount=0): - args = frappe._dict(args) +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] - posting_date = getdate(args.get("posting_date")) - posting_fiscal_year = get_fiscal_year(posting_date, company=args.get("company"))[0] + 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) budget_exists = frappe.db.sql( @@ -356,24 +356,24 @@ def validate_expense_against_budget(args, expense_amount=0): and (SELECT year_end_date FROM `tabFiscal Year` WHERE name = to_fiscal_year) >= %s limit 1 """, - (args.company, year_end_date, year_start_date), + (params.company, year_end_date, year_start_date), ) if not budget_exists: return - if args.get("company"): + if params.get("company"): frappe.flags.exception_approver_role = frappe.get_cached_value( - "Company", args.get("company"), "exception_budget_approver_role" + "Company", params.get("company"), "exception_budget_approver_role" ) - if not args.account: - args.account = args.get("expense_account") + if not params.account: + params.account = params.get("expense_account") - 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.get("account") and params.get("cost_center")) and params.item_code: + params.cost_center, params.account = get_item_details(params) - if not args.account: + if not params.account: return default_dimensions = [ @@ -391,23 +391,23 @@ 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""" @@ -437,29 +437,32 @@ def validate_expense_against_budget(args, expense_amount=0): AND b.account = %s {condition} """, - (args.company, args.posting_date, 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 - args["from_fiscal_year"], args["to_fiscal_year"] = budget.from_fiscal_year, budget.to_fiscal_year - args["budget_start_date"], args["budget_end_date"] = ( + 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, @@ -468,12 +471,12 @@ def validate_budget_records(args, budget_records, expense_amount): ) if monthly_action in ["Stop", "Warn"]: - budget_amount = get_accumulated_monthly_budget(budget.name, args.posting_date) + 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, @@ -482,38 +485,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 @@ -526,19 +532,19 @@ 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): +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", args.from_fiscal_year, "year_start_date") - to_date = frappe.get_cached_value("Fiscal Year", args.to_fiscal_year, "year_end_date") + 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( { @@ -556,7 +562,7 @@ def get_expense_breakup(args, currency, budget_against): 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() @@ -566,7 +572,7 @@ def get_expense_breakup(args, currency, budget_against): "docstatus": 1, "material_request_type": "Purchase", "schedule_date": [["between", [from_date, to_date]]], - "item_code": args.item_code, + "item_code": params.item_code, "per_ordered": [["<", 100]], } ) @@ -581,7 +587,7 @@ def get_expense_breakup(args, currency, budget_against): filters=mr_filters, ) + " - " - + frappe.bold(fmt_money(args.requested_amount, currency=currency)) + + frappe.bold(fmt_money(params.requested_amount, currency=currency)) + "" ) @@ -591,7 +597,7 @@ def get_expense_breakup(args, currency, budget_against): "status": [["!=", "Closed"]], "docstatus": 1, "transaction_date": [["between", [from_date, to_date]]], - "item_code": args.item_code, + "item_code": params.item_code, "per_billed": [["<", 100]], } ) @@ -606,7 +612,7 @@ def get_expense_breakup(args, currency, budget_against): filters=po_filters, ) + " - " - + frappe.bold(fmt_money(args.ordered_amount, currency=currency)) + + frappe.bold(fmt_money(params.ordered_amount, currency=currency)) + "
      " ) From 9ebf546e1faadde11d1d730b9dc43a397115f895 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 15:16:09 +0530 Subject: [PATCH 25/28] refactor: patch for migration --- ...migrate_budget_records_to_new_structure.py | 215 ++++++++++-------- 1 file changed, 118 insertions(+), 97 deletions(-) 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 index 1051184c4a2..bde5c2984c7 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -3,105 +3,126 @@ from frappe.utils import add_months, flt, get_first_day, get_last_day def execute(): - budgets = frappe.get_all("Budget", filters={"docstatus": ["in", [0, 1]]}, pluck="name") + remove_old_property_setter() - for budget in budgets: - old_budget = frappe.get_doc("Budget", budget) + budget_names = frappe.db.get_list( + "Budget", + filters={"docstatus": ["in", [0, 1]]}, + pluck="name", + ) - accounts = frappe.get_all( - "Budget Account", - filters={"parent": old_budget.name}, - fields=["account", "budget_amount"], - order_by="idx asc", + 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", {"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) - if not accounts: - continue + new_budget.flags.ignore_validate = True + new_budget.flags.ignore_links = True + new_budget.insert(ignore_permissions=True, ignore_mandatory=True) - old_distribution = [] - if old_budget.monthly_distribution: - old_distribution = frappe.get_all( - "Monthly Distribution Percentage", - filters={"parent": old_budget.monthly_distribution}, - fields=["percentage_allocation"], - order_by="idx asc", - ) - - if old_distribution: - percentage_list = [flt(d.percentage_allocation) for d in old_distribution] - else: - percentage_list = [100 / 12] * 12 - - fy = frappe.get_doc("Fiscal Year", old_budget.fiscal_year) - fy_start = fy.year_start_date - fy_end = fy.year_end_date - - for account in accounts: - new = frappe.new_doc("Budget") - - new.naming_series = "BUDGET-.########" - new.budget_against = old_budget.budget_against - new.company = old_budget.company - new.cost_center = old_budget.cost_center - new.project = old_budget.project - new.fiscal_year = fy.name - - new.from_fiscal_year = fy.name - new.to_fiscal_year = fy.name - new.budget_start_date = fy_start - new.budget_end_date = fy_end - - new.account = account.account - new.budget_amount = flt(account.budget_amount) - new.distribution_frequency = "Monthly" - - new.distribute_equally = 1 if len(set(percentage_list)) == 1 else 0 - - fields_to_copy = [ - "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 fields_to_copy: - if hasattr(old_budget, field): - new.set(field, old_budget.get(field)) - - start = fy_start - for percentage in percentage_list: - row_start = get_first_day(start) - row_end = get_last_day(start) - - new.append( - "budget_distribution", - { - "start_date": row_start, - "end_date": row_end, - "percent": percentage, - "amount": new.budget_amount * percentage / 100, - }, - ) - - start = add_months(start, 1) - - new.flags.ignore_validate = True - new.flags.ignore_links = True - - new.insert(ignore_permissions=True, ignore_mandatory=True) - - if old_budget.docstatus == 1: - new.submit() - - if old_budget.docstatus == 1: - old_budget.cancel() - else: - old_budget.delete() + if budget_doc.docstatus == 1: + new_budget.submit() From 8fd5d7187a99ffc32114244b41a2bc9290eccaaa Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 15:55:57 +0530 Subject: [PATCH 26/28] refactor: replace args with params --- erpnext/accounts/doctype/budget/budget.py | 84 ++++++++++++----------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 5a0f2b0dde4..15ed0eb7317 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -186,7 +186,7 @@ class Budget(Document): if self.is_new() and self.revision_of: return - args = frappe._dict( + params = frappe._dict( { "company": self.company, "account": self.account, @@ -197,14 +197,14 @@ class Budget(Document): } ) - args[args.budget_against_field] = self.get(args.budget_against_field) + params[params.budget_against_field] = self.get(params.budget_against_field) - if frappe.get_cached_value("DocType", args.budget_against_doctype, "is_tree"): - args.is_tree = True + if frappe.get_cached_value("DocType", params.budget_against_doctype, "is_tree"): + params.is_tree = True else: - args.is_tree = False + params.is_tree = False - actual_spent = get_actual_expense(args) + actual_spent = get_actual_expense(params) if actual_spent > self.budget_amount: frappe.throw( @@ -619,24 +619,24 @@ def get_expense_breakup(params, currency, budget_against): 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 @@ -650,9 +650,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 @@ -666,41 +666,43 @@ def get_ordered_amount(args): return data[0][0] if data else 0 -def get_other_condition(args, for_doc): - condition = f"expense_account = '{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)}'" date_field = "schedule_date" if for_doc == "Material Request" else "transaction_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") + 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 "" - date_condition = f"and gle.posting_date between '{args.budget_start_date}' and '{args.budget_end_date}'" + date_condition = ( + f"and gle.posting_date between '{params.budget_start_date}' and '{params.budget_end_date}'" + ) - if args.is_tree: + 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 ) - args.update(lft_rgt) + params.update(lft_rgt) condition2 = f""" and exists( - select name from `tab{args.budget_against_doctype}` + select name from `tab{params.budget_against_doctype}` where lft >= %(lft)s and rgt <= %(rgt)s and name = gle.{budget_against_field} ) @@ -724,7 +726,7 @@ def get_actual_expense(args): and gle.docstatus = 1 {condition2} """, - args, + params, )[0][0] ) # nosec @@ -750,16 +752,16 @@ def get_accumulated_monthly_budget(budget_name, posting_date): 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: @@ -767,7 +769,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] @@ -781,16 +783,16 @@ 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"] ) From acec1a7a9d7c1b6690c4c69ad916f06b27e67eb4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Wed, 19 Nov 2025 17:05:26 +0530 Subject: [PATCH 27/28] fix: permission based revision of budget --- erpnext/accounts/doctype/budget/budget.js | 34 ++++++++++----------- erpnext/accounts/doctype/budget/budget.json | 3 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 4b2aefffb57..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,17 +20,27 @@ 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) { - frm.add_custom_button( - __("Revise Budget"), - function () { - frm.events.revise_budget_action(frm); - }, - __("Actions") + 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") + ); + } } }, diff --git a/erpnext/accounts/doctype/budget/budget.json b/erpnext/accounts/doctype/budget/budget.json index bf1e7d091c1..8476a2831f0 100644 --- a/erpnext/accounts/doctype/budget/budget.json +++ b/erpnext/accounts/doctype/budget/budget.json @@ -184,6 +184,7 @@ "options": "\nStop\nWarn\nIgnore" }, { + "default": "BUDGET-.########", "fieldname": "naming_series", "fieldtype": "Select", "label": "Series", @@ -308,7 +309,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-17 17:38:27.759355", + "modified": "2025-11-19 17:00:00.648224", "modified_by": "Administrator", "module": "Accounts", "name": "Budget", From c3ff5e3748977f4f8648d4fed096d493c7d33915 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 20 Nov 2025 12:18:03 +0530 Subject: [PATCH 28/28] fix: multiple minor fixes --- erpnext/accounts/doctype/budget/budget.py | 8 ++++++++ erpnext/accounts/doctype/budget/test_budget.py | 2 +- erpnext/controllers/budget_controller.py | 2 +- .../v16_0/migrate_budget_records_to_new_structure.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 15ed0eb7317..d798da5b589 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -69,6 +69,7 @@ class Budget(Document): 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() @@ -77,6 +78,10 @@ class Budget(Document): 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) @@ -370,6 +375,9 @@ def validate_expense_against_budget(params, expense_amount=0): if not params.account: params.account = params.get("expense_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) diff --git a/erpnext/accounts/doctype/budget/test_budget.py b/erpnext/accounts/doctype/budget/test_budget.py index dd31cdc636f..ba9b4c04e08 100644 --- a/erpnext/accounts/doctype/budget/test_budget.py +++ b/erpnext/accounts/doctype/budget/test_budget.py @@ -619,7 +619,7 @@ def set_total_expense_zero(posting_date, budget_against_field=None, budget_again { "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", "from_fiscal_year": fiscal_year, "to_fiscal_year": fiscal_year, diff --git a/erpnext/controllers/budget_controller.py b/erpnext/controllers/budget_controller.py index 33afac4df22..5c7692a4433 100644 --- a/erpnext/controllers/budget_controller.py +++ b/erpnext/controllers/budget_controller.py @@ -428,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/v16_0/migrate_budget_records_to_new_structure.py b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py index bde5c2984c7..c9a18ebff31 100644 --- a/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py +++ b/erpnext/patches/v16_0/migrate_budget_records_to_new_structure.py @@ -44,7 +44,7 @@ def migrate_single_budget(budget_name): if not account_rows: return - frappe.db.delete("Budget Account", {"parent": budget_doc.name}) + frappe.db.delete("Budget Account", filters={"parent": budget_doc.name}) percentage_allocations = get_percentage_allocations(budget_doc)