From 7825ddf989297a4ee7ddffb6e8ee1a334c0b8c17 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Tue, 9 Jun 2026 16:11:35 +0530 Subject: [PATCH] refactor(journal_entry): extract asset linkage into a service Move the nine asset/depreciation coupling methods (depreciation-account validation, asset value updates on depreciation and disposal, and the unlink-on-cancel logic) out of the controller into a JournalEntryAssetLinkage service under services/. Pure behaviour-preserving move, netted by the asset suite (asset, asset_value_adjustment) plus the JE module. --- .../doctype/journal_entry/journal_entry.py | 175 ++--------------- .../journal_entry/services/asset_linkage.py | 181 ++++++++++++++++++ 2 files changed, 193 insertions(+), 163 deletions(-) create mode 100644 erpnext/accounts/doctype/journal_entry/services/asset_linkage.py diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 6f73be40fb3..af4c6789667 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -27,9 +27,6 @@ from erpnext.accounts.utils import ( get_stock_accounts, get_stock_and_account_balance, ) -from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( - get_depr_schedule, -) from erpnext.controllers.accounts_controller import AccountsController from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate @@ -124,6 +121,7 @@ class JournalEntry(AccountsController): super().__init__(*args, **kwargs) def validate(self): + from erpnext.accounts.doctype.journal_entry.services.asset_linkage import JournalEntryAssetLinkage from erpnext.accounts.doctype.journal_entry.services.reference_validator import ( JournalEntryReferenceValidator, ) @@ -155,7 +153,7 @@ class JournalEntry(AccountsController): self.validate_credit_debit_note() self.validate_empty_accounts_table() self.validate_inter_company_accounts() - self.validate_depr_account_and_depr_entry_voucher_type() + JournalEntryAssetLinkage(self).validate_depr_account_and_depr_entry_voucher_type() self.validate_company_in_accounting_dimension() self.validate_advance_accounts() @@ -189,7 +187,9 @@ class JournalEntry(AccountsController): return self._submit() def before_cancel(self): - self.has_asset_adjustment_entry() + from erpnext.accounts.doctype.journal_entry.services.asset_linkage import JournalEntryAssetLinkage + + JournalEntryAssetLinkage(self).has_asset_adjustment_entry() def cancel(self): if len(self.accounts) > 100: @@ -203,10 +203,12 @@ class JournalEntry(AccountsController): self.validate_total_debit_and_credit() def on_submit(self): + from erpnext.accounts.doctype.journal_entry.services.asset_linkage import JournalEntryAssetLinkage + self.validate_cheque_info() self.make_gl_entries() self.check_credit_limit() - self.update_asset_value() + JournalEntryAssetLinkage(self).update_asset_value() self.update_inter_company_jv() self.update_invoice_discounting() JournalTaxWithholding(self).on_submit() @@ -295,6 +297,8 @@ class JournalEntry(AccountsController): def on_cancel(self): # Cancel tax withholding entries + from erpnext.accounts.doctype.journal_entry.services.asset_linkage import JournalEntryAssetLinkage + # References for this Journal are removed on the `on_cancel` event in accounts_controller super().on_cancel() @@ -319,9 +323,9 @@ class JournalEntry(AccountsController): self.make_gl_entries(1) JournalTaxWithholding(self).on_cancel() self.unlink_advance_entry_reference() - self.unlink_asset_reference() + JournalEntryAssetLinkage(self).unlink_asset_reference() self.unlink_inter_company_jv() - self.unlink_asset_adjustment_entry() + JournalEntryAssetLinkage(self).unlink_asset_adjustment_entry() self.update_invoice_discounting() def get_title(self): @@ -345,17 +349,6 @@ class JournalEntry(AccountsController): ): frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry")) - def validate_depr_account_and_depr_entry_voucher_type(self): - for d in self.get("accounts"): - if d.account_type == "Depreciation": - if self.voucher_type != "Depreciation Entry": - frappe.throw( - _("Journal Entry type should be set as Depreciation Entry for asset depreciation") - ) - - if frappe.get_cached_value("Account", d.account, "root_type") != "Expense": - frappe.throw(_("Account {0} should be of type Expense").format(d.account)) - def validate_stock_accounts(self): if ( not erpnext.is_perpetual_inventory_enabled(self.company) @@ -376,75 +369,6 @@ class JournalEntry(AccountsController): StockAccountInvalidTransaction, ) - def update_asset_value(self): - self.update_asset_on_depreciation() - self.update_asset_on_disposal() - - def update_asset_on_depreciation(self): - if self.voucher_type != "Depreciation Entry": - return - - for d in self.get("accounts"): - if ( - d.reference_type == "Asset" - and d.reference_name - and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" - and d.debit - ): - asset = frappe.get_cached_doc("Asset", d.reference_name) - - if asset.calculate_depreciation: - self.update_journal_entry_link_on_depr_schedule(asset, d) - self.update_value_after_depreciation(asset, d.debit) - - asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit) - asset.set_status() - asset.set_total_booked_depreciations() - - def update_value_after_depreciation(self, asset, depr_amount): - fb_idx = 1 - if self.finance_book: - for fb_row in asset.get("finance_books"): - if fb_row.finance_book == self.finance_book: - fb_idx = fb_row.idx - break - fb_row = asset.get("finance_books")[fb_idx - 1] - fb_row.value_after_depreciation -= depr_amount - frappe.db.set_value( - "Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation - ) - - def update_journal_entry_link_on_depr_schedule(self, asset, je_row): - depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book) - for d in depr_schedule or []: - if ( - d.schedule_date == self.posting_date - and not d.journal_entry - and d.depreciation_amount == flt(je_row.debit) - ): - frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name) - - def update_asset_on_disposal(self): - if self.voucher_type == "Asset Disposal": - disposed_assets = [] - for d in self.get("accounts"): - if ( - d.reference_type == "Asset" - and d.reference_name - and d.reference_name not in disposed_assets - ): - frappe.db.set_value( - "Asset", - d.reference_name, - { - "disposal_date": self.posting_date, - "journal_entry_for_scrap": self.name, - }, - ) - asset_doc = frappe.get_doc("Asset", d.reference_name) - asset_doc.set_status() - disposed_assets.append(d.reference_name) - def update_inter_company_jv(self): if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference: frappe.db.set_value( @@ -507,59 +431,6 @@ class JournalEntry(AccountsController): d.reference_name = "" d.db_update() - def unlink_asset_reference(self): - for d in self.get("accounts"): - if ( - self.voucher_type == "Depreciation Entry" - and d.reference_type == "Asset" - and d.reference_name - and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" - and d.debit - ): - asset = frappe.get_doc("Asset", d.reference_name) - - if asset.calculate_depreciation: - je_found = False - - for fb_row in asset.get("finance_books"): - if je_found: - break - - depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book) - - for s in depr_schedule or []: - if s.journal_entry == self.name: - s.db_set("journal_entry", None) - - fb_row.value_after_depreciation += d.debit - fb_row.db_update() - - je_found = True - break - if not je_found: - fb_idx = 1 - if self.finance_book: - for fb_row in asset.get("finance_books"): - if fb_row.finance_book == self.finance_book: - fb_idx = fb_row.idx - break - - fb_row = asset.get("finance_books")[fb_idx - 1] - fb_row.value_after_depreciation += d.debit - fb_row.db_update() - asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit) - asset.set_status() - asset.set_total_booked_depreciations() - elif self.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name: - journal_entry_for_scrap = frappe.db.get_value( - "Asset", d.reference_name, "journal_entry_for_scrap" - ) - - if journal_entry_for_scrap == self.name: - frappe.throw( - _("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.") - ) - def unlink_inter_company_jv(self): if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference: frappe.db.set_value( @@ -570,28 +441,6 @@ class JournalEntry(AccountsController): ) frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "") - def has_asset_adjustment_entry(self): - if self.flags.get("via_asset_value_adjustment"): - return - - asset_value_adjustment = frappe.db.get_value( - "Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name" - ) - if asset_value_adjustment: - frappe.throw( - _( - "Cannot cancel this document as it is linked with the submitted Asset Value Adjustment {0}. Please cancel the Asset Value Adjustment to continue." - ).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment)) - ) - - def unlink_asset_adjustment_entry(self): - AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment") - ( - frappe.qb.update(AssetValueAdjustment) - .set(AssetValueAdjustment.journal_entry, None) - .where(AssetValueAdjustment.journal_entry == self.name) - ).run() - def validate_party(self): for d in self.get("accounts"): account_type = frappe.get_cached_value("Account", d.account, "account_type") diff --git a/erpnext/accounts/doctype/journal_entry/services/asset_linkage.py b/erpnext/accounts/doctype/journal_entry/services/asset_linkage.py new file mode 100644 index 00000000000..946291972ac --- /dev/null +++ b/erpnext/accounts/doctype/journal_entry/services/asset_linkage.py @@ -0,0 +1,181 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( + get_depr_schedule, +) + + +class JournalEntryAssetLinkage: + """Keeps Assets in sync with the Journal Entries that depreciate, dispose or + adjust them. + + On submit of a Depreciation Entry it reduces the asset value and links the + depreciation schedule; on submit of an Asset Disposal it marks the asset + disposed. On cancel it reverses those links. It also guards cancellation of + Journal Entries tied to asset scrapping or value adjustments. + """ + + def __init__(self, doc): + self.doc = doc + + def validate_depr_account_and_depr_entry_voucher_type(self): + for d in self.doc.get("accounts"): + if d.account_type == "Depreciation": + if self.doc.voucher_type != "Depreciation Entry": + frappe.throw( + _("Journal Entry type should be set as Depreciation Entry for asset depreciation") + ) + + if frappe.get_cached_value("Account", d.account, "root_type") != "Expense": + frappe.throw(_("Account {0} should be of type Expense").format(d.account)) + + def has_asset_adjustment_entry(self): + if self.doc.flags.get("via_asset_value_adjustment"): + return + + asset_value_adjustment = frappe.db.get_value( + "Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name" + ) + if asset_value_adjustment: + frappe.throw( + _( + "Cannot cancel this document as it is linked with the submitted Asset Value Adjustment {0}. Please cancel the Asset Value Adjustment to continue." + ).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment)) + ) + + def update_asset_value(self): + self.update_asset_on_depreciation() + self.update_asset_on_disposal() + + def update_asset_on_depreciation(self): + if self.doc.voucher_type != "Depreciation Entry": + return + + for d in self.doc.get("accounts"): + if ( + d.reference_type == "Asset" + and d.reference_name + and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" + and d.debit + ): + asset = frappe.get_cached_doc("Asset", d.reference_name) + + if asset.calculate_depreciation: + self.update_journal_entry_link_on_depr_schedule(asset, d) + self.update_value_after_depreciation(asset, d.debit) + + asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit) + asset.set_status() + asset.set_total_booked_depreciations() + + def update_value_after_depreciation(self, asset, depr_amount): + fb_idx = 1 + if self.doc.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.doc.finance_book: + fb_idx = fb_row.idx + break + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation -= depr_amount + frappe.db.set_value( + "Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation + ) + + def update_journal_entry_link_on_depr_schedule(self, asset, je_row): + depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book) + for d in depr_schedule or []: + if ( + d.schedule_date == self.doc.posting_date + and not d.journal_entry + and d.depreciation_amount == flt(je_row.debit) + ): + frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name) + + def update_asset_on_disposal(self): + if self.doc.voucher_type == "Asset Disposal": + disposed_assets = [] + for d in self.doc.get("accounts"): + if ( + d.reference_type == "Asset" + and d.reference_name + and d.reference_name not in disposed_assets + ): + frappe.db.set_value( + "Asset", + d.reference_name, + { + "disposal_date": self.doc.posting_date, + "journal_entry_for_scrap": self.doc.name, + }, + ) + asset_doc = frappe.get_doc("Asset", d.reference_name) + asset_doc.set_status() + disposed_assets.append(d.reference_name) + + def unlink_asset_reference(self): + for d in self.doc.get("accounts"): + if ( + self.doc.voucher_type == "Depreciation Entry" + and d.reference_type == "Asset" + and d.reference_name + and frappe.get_cached_value("Account", d.account, "root_type") == "Expense" + and d.debit + ): + asset = frappe.get_doc("Asset", d.reference_name) + + if asset.calculate_depreciation: + je_found = False + + for fb_row in asset.get("finance_books"): + if je_found: + break + + depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book) + + for s in depr_schedule or []: + if s.journal_entry == self.doc.name: + s.db_set("journal_entry", None) + + fb_row.value_after_depreciation += d.debit + fb_row.db_update() + + je_found = True + break + if not je_found: + fb_idx = 1 + if self.doc.finance_book: + for fb_row in asset.get("finance_books"): + if fb_row.finance_book == self.doc.finance_book: + fb_idx = fb_row.idx + break + + fb_row = asset.get("finance_books")[fb_idx - 1] + fb_row.value_after_depreciation += d.debit + fb_row.db_update() + asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit) + asset.set_status() + asset.set_total_booked_depreciations() + elif ( + self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name + ): + journal_entry_for_scrap = frappe.db.get_value( + "Asset", d.reference_name, "journal_entry_for_scrap" + ) + + if journal_entry_for_scrap == self.doc.name: + frappe.throw( + _("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.") + ) + + def unlink_asset_adjustment_entry(self): + AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment") + ( + frappe.qb.update(AssetValueAdjustment) + .set(AssetValueAdjustment.journal_entry, None) + .where(AssetValueAdjustment.journal_entry == self.doc.name) + ).run()