mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-02 03:39:11 +00:00
fix: validate existing expenses when revising or modifying budget amounts
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
"to_fiscal_year",
|
"to_fiscal_year",
|
||||||
"budget_start_date",
|
"budget_start_date",
|
||||||
"budget_end_date",
|
"budget_end_date",
|
||||||
"allocation_frequency",
|
"distribution_frequency",
|
||||||
"budget_amount",
|
"budget_amount",
|
||||||
"section_break_nwug",
|
"section_break_nwug",
|
||||||
"distribute_equally",
|
"distribute_equally",
|
||||||
@@ -238,15 +238,6 @@
|
|||||||
"read_only_depends_on": "eval: doc.revision_of",
|
"read_only_depends_on": "eval: doc.revision_of",
|
||||||
"reqd": 1
|
"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",
|
"fieldname": "budget_amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
@@ -302,13 +293,22 @@
|
|||||||
"fieldtype": "Date",
|
"fieldtype": "Date",
|
||||||
"hidden": 1,
|
"hidden": 1,
|
||||||
"label": "Budget End Date"
|
"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,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-05 01:00:46.470251",
|
"modified": "2025-11-06 10:36:35.565701",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Budget",
|
"name": "Budget",
|
||||||
|
|||||||
@@ -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_mr: DF.Literal["", "Stop", "Warn", "Ignore"]
|
||||||
action_if_annual_budget_exceeded_on_po: 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"]
|
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
|
amended_from: DF.Link | None
|
||||||
applicable_on_booking_actual_expenses: DF.Check
|
applicable_on_booking_actual_expenses: DF.Check
|
||||||
applicable_on_cumulative_expense: DF.Check
|
applicable_on_cumulative_expense: DF.Check
|
||||||
@@ -59,6 +58,7 @@ class Budget(Document):
|
|||||||
company: DF.Link
|
company: DF.Link
|
||||||
cost_center: DF.Link | None
|
cost_center: DF.Link | None
|
||||||
distribute_equally: DF.Check
|
distribute_equally: DF.Check
|
||||||
|
distribution_frequency: DF.Literal["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
|
||||||
from_fiscal_year: DF.Link
|
from_fiscal_year: DF.Link
|
||||||
naming_series: DF.Literal["BUDGET-.YYYY.-"]
|
naming_series: DF.Literal["BUDGET-.YYYY.-"]
|
||||||
project: DF.Link | None
|
project: DF.Link | None
|
||||||
@@ -75,6 +75,7 @@ class Budget(Document):
|
|||||||
self.validate_account()
|
self.validate_account()
|
||||||
self.set_null_value()
|
self.set_null_value()
|
||||||
self.validate_applicable_for()
|
self.validate_applicable_for()
|
||||||
|
self.validate_existing_expenses()
|
||||||
|
|
||||||
def validate_fiscal_year(self):
|
def validate_fiscal_year(self):
|
||||||
if self.from_fiscal_year:
|
if self.from_fiscal_year:
|
||||||
@@ -94,12 +95,14 @@ class Budget(Document):
|
|||||||
self.budget_start_date = frappe.get_cached_value(
|
self.budget_start_date = frappe.get_cached_value(
|
||||||
"Fiscal Year", self.from_fiscal_year, "year_start_date"
|
"Fiscal Year", self.from_fiscal_year, "year_start_date"
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.to_fiscal_year:
|
if self.to_fiscal_year:
|
||||||
self.budget_end_date = frappe.get_cached_value(
|
self.budget_end_date = frappe.get_cached_value(
|
||||||
"Fiscal Year", self.to_fiscal_year, "year_end_date"
|
"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):
|
def validate_duplicate(self):
|
||||||
budget_against_field = frappe.scrub(self.budget_against)
|
budget_against_field = frappe.scrub(self.budget_against)
|
||||||
budget_against = self.get(budget_against_field)
|
budget_against = self.get(budget_against_field)
|
||||||
@@ -179,6 +182,47 @@ class Budget(Document):
|
|||||||
):
|
):
|
||||||
self.applicable_on_booking_actual_expenses = 1
|
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):
|
def before_save(self):
|
||||||
self.allocate_budget()
|
self.allocate_budget()
|
||||||
|
|
||||||
@@ -215,7 +259,7 @@ class Budget(Document):
|
|||||||
"from_fiscal_year",
|
"from_fiscal_year",
|
||||||
"to_fiscal_year",
|
"to_fiscal_year",
|
||||||
"budget_amount",
|
"budget_amount",
|
||||||
"allocation_frequency",
|
"distribution_frequency",
|
||||||
"distribute_equally",
|
"distribute_equally",
|
||||||
]
|
]
|
||||||
for field in changed_fields:
|
for field in changed_fields:
|
||||||
@@ -226,7 +270,7 @@ class Budget(Document):
|
|||||||
|
|
||||||
def get_budget_periods(self):
|
def get_budget_periods(self):
|
||||||
"""Return list of (start_date, end_date) tuples based on frequency."""
|
"""Return list of (start_date, end_date) tuples based on frequency."""
|
||||||
frequency = self.allocation_frequency
|
frequency = self.distribution_frequency
|
||||||
periods = []
|
periods = []
|
||||||
|
|
||||||
start_date = getdate(self.budget_start_date)
|
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:
|
if flt(abs(total_amount - self.budget_amount), 2) > 0.10:
|
||||||
frappe.throw(
|
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
|
flt(total_amount, 2), self.budget_amount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -446,24 +446,23 @@ class TestBudget(ERPNextTestSuite):
|
|||||||
po.cancel()
|
po.cancel()
|
||||||
jv.cancel()
|
jv.cancel()
|
||||||
|
|
||||||
def test_distribution_date_validation(self):
|
def test_fiscal_year_validation(self):
|
||||||
budget = frappe.new_doc("Budget")
|
frappe.get_doc(
|
||||||
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",
|
|
||||||
{
|
{
|
||||||
"start_date": start,
|
"doctype": "Fiscal Year",
|
||||||
"end_date": end,
|
"year": "2100",
|
||||||
"amount": 50000,
|
"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):
|
with self.assertRaises(frappe.ValidationError):
|
||||||
@@ -473,35 +472,14 @@ class TestBudget(ERPNextTestSuite):
|
|||||||
budget = make_budget(
|
budget = make_budget(
|
||||||
budget_against="Cost Center",
|
budget_against="Cost Center",
|
||||||
applicable_on_cumulative_expense=True,
|
applicable_on_cumulative_expense=True,
|
||||||
|
distribute_equally=0,
|
||||||
|
budget_amount=12000,
|
||||||
do_not_save=False,
|
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")
|
for row in budget.budget_distribution:
|
||||||
budget.end_date = getdate("2025-06-30")
|
row.amount = 2000
|
||||||
|
|
||||||
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):
|
with self.assertRaises(frappe.ValidationError):
|
||||||
budget.save()
|
budget.save()
|
||||||
@@ -531,6 +509,7 @@ class TestBudget(ERPNextTestSuite):
|
|||||||
self.assertEqual(old_budget.docstatus, 2)
|
self.assertEqual(old_budget.docstatus, 2)
|
||||||
|
|
||||||
def test_revision_preserves_distribution(self):
|
def test_revision_preserves_distribution(self):
|
||||||
|
set_total_expense_zero(nowdate(), "cost_center", "_Test Cost Center - _TC")
|
||||||
budget = make_budget(
|
budget = make_budget(
|
||||||
budget_against="Cost Center", budget_amount=120000, do_not_save=False, submit_budget=True
|
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"
|
budget_against = budget_against_CC or "_Test Cost Center - _TC"
|
||||||
|
|
||||||
fiscal_year = get_fiscal_year(nowdate())[0]
|
fiscal_year = get_fiscal_year(nowdate())[0]
|
||||||
|
fiscal_year_start_date, fiscal_year_end_date = get_fiscal_year(nowdate())[1:3]
|
||||||
|
|
||||||
args = frappe._dict(
|
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,
|
"from_fiscal_year": fiscal_year,
|
||||||
"to_fiscal_year": fiscal_year,
|
"to_fiscal_year": fiscal_year,
|
||||||
"budget_against_field": budget_against_field,
|
"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):
|
if not args.get(budget_against_field):
|
||||||
args[budget_against_field] = budget_against
|
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)
|
existing_expense = get_actual_expense(args)
|
||||||
|
|
||||||
if existing_expense:
|
if existing_expense:
|
||||||
@@ -714,8 +703,8 @@ def make_budget(**args):
|
|||||||
else:
|
else:
|
||||||
budget.cost_center = cost_center or "_Test Cost Center - _TC"
|
budget.cost_center = cost_center or "_Test Cost Center - _TC"
|
||||||
|
|
||||||
budget.from_fiscal_year = fiscal_year
|
budget.from_fiscal_year = args.from_fiscal_year or fiscal_year
|
||||||
budget.to_fiscal_year = fiscal_year
|
budget.to_fiscal_year = args.to_fiscal_year or fiscal_year
|
||||||
budget.company = "_Test Company"
|
budget.company = "_Test Company"
|
||||||
budget.account = "_Test Account Cost for Goods Sold - _TC"
|
budget.account = "_Test Account Cost for Goods Sold - _TC"
|
||||||
budget.budget_amount = args.budget_amount or 200000
|
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.action_if_accumulated_monthly_budget_exceeded = "Ignore"
|
||||||
budget.budget_against = budget_against
|
budget.budget_against = budget_against
|
||||||
|
|
||||||
budget.allocation_frequency = "Monthly"
|
budget.distribution_frequency = "Monthly"
|
||||||
budget.distribute_equally = args.get("distribute_equally", 1)
|
budget.distribute_equally = args.get("distribute_equally", 1)
|
||||||
|
|
||||||
if args.applicable_on_material_request:
|
if args.applicable_on_material_request:
|
||||||
|
|||||||
Reference in New Issue
Block a user