From e5f9698055602e9591f2a4a1ca4ab63ca60e73f1 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Sun, 7 Jun 2026 10:03:41 +0530 Subject: [PATCH] refactor(accounts): extract Purchase Invoice ExpenseAccountService Move expense-account resolution into accounts/doctype/purchase_invoice/services/expense_account.py: - set_expense_account stays as a controller delegator (dispatched from accounts_controller.py) and force_set_against_expense_account stays (called by repost_accounting_ledger) - validate_expense_account and set_against_expense_account move into the service; validate() repointed (the unused force kwarg on set_against_expense_account is dropped with the method) Pre-existing raw SQL (SRBNB-booked-in-PR check) moved verbatim. No behaviour change. --- .../purchase_invoice/purchase_invoice.py | 170 +--------------- .../services/expense_account.py | 183 ++++++++++++++++++ 2 files changed, 190 insertions(+), 163 deletions(-) create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/expense_account.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index a34ff1485f5..8110288ce39 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -10,6 +10,7 @@ from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date +from erpnext.accounts.doctype.purchase_invoice.services.expense_account import ExpenseAccountService from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import ( validate_docs_for_deferred_accounting, validate_docs_for_voucher_types, @@ -32,8 +33,6 @@ from erpnext.accounts.general_ledger import ( from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled -from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account -from erpnext.controllers.accounts_controller import validate_account_head from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.purchase_receipt.services.billing_status import ( update_billed_amount_based_on_po, @@ -284,9 +283,10 @@ class PurchaseInvoice(BuyingController): self.validate_with_previous_doc() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "stock_qty") - self.set_expense_account(for_validate=True) - self.validate_expense_account() - self.set_against_expense_account() + expense_account_service = ExpenseAccountService(self) + expense_account_service.set_expense_account(for_validate=True) + expense_account_service.validate_expense_account() + expense_account_service.set_against_expense_account() self.validate_write_off_account() self.validate_write_off_cost_center() @@ -446,166 +446,10 @@ class PurchaseInvoice(BuyingController): frappe.msgprint(_("Item Code required at Row No {0}").format(d.idx), raise_exception=True) def set_expense_account(self, for_validate=False): - auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) - - if auto_accounting_for_stock: - stock_not_billed_account = self.get_company_default("stock_received_but_not_billed") - stock_items = self.get_stock_items() - - self.asset_received_but_not_billed = None - - inventory_account_map = {} - if self.update_stock: - self.validate_item_code() - self.validate_warehouse(for_validate) - if auto_accounting_for_stock: - inventory_account_map = self.get_inventory_account_map() - - for item in self.get("items"): - # in case of auto inventory accounting, - # expense account is always "Stock Received But Not Billed" for a stock item - # except opening entry, drop-ship entry and fixed asset items - if ( - auto_accounting_for_stock - and item.item_code in stock_items - and self.is_opening == "No" - and not item.is_fixed_asset - and ( - not item.po_detail - or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier") - ) - ): - if self.update_stock and item.warehouse and (not item.from_warehouse): - _inv_dict = self.get_inventory_account_dict(item, inventory_account_map) - - if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]: - msg = _( - "Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account" - ).format( - item.idx, - frappe.bold(_inv_dict["account"]), - frappe.bold(item.expense_account), - frappe.bold(item.warehouse), - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - item.expense_account = _inv_dict["account"] - else: - # check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not - if item.purchase_receipt: - negative_expense_booked_in_pr = frappe.db.sql( - """select name from `tabGL Entry` - where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""", - (item.purchase_receipt, stock_not_billed_account), - ) - - if negative_expense_booked_in_pr: - if ( - for_validate - and item.expense_account - and item.expense_account != stock_not_billed_account - ): - msg = _( - "Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}" - ).format( - item.idx, - frappe.bold(stock_not_billed_account), - frappe.bold(item.purchase_receipt), - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - - item.expense_account = stock_not_billed_account - else: - # If no purchase receipt present then book expense in 'Stock Received But Not Billed' - # This is done in cases when Purchase Invoice is created before Purchase Receipt - if ( - for_validate - and item.expense_account - and item.expense_account != stock_not_billed_account - ): - msg = _( - "Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}." - ).format( - item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code) - ) - msg += "
" - msg += _( - "This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice" - ) - frappe.msgprint(msg, title=_("Expense Head Changed")) - - item.expense_account = stock_not_billed_account - elif item.is_fixed_asset: - account = None - if not item.pr_detail and item.po_detail: - receipt_item = frappe.get_cached_value( - "Purchase Receipt Item", - { - "purchase_order": item.purchase_order, - "purchase_order_item": item.po_detail, - "docstatus": 1, - }, - ["name", "parent"], - as_dict=1, - ) - if receipt_item: - item.pr_detail = receipt_item.name - item.purchase_receipt = receipt_item.parent - - if item.pr_detail: - if not self.asset_received_but_not_billed: - self.asset_received_but_not_billed = self.get_company_default( - "asset_received_but_not_billed" - ) - - # check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not - arbnb_booked_in_pr = frappe.db.get_value( - "GL Entry", - { - "voucher_type": "Purchase Receipt", - "voucher_no": item.purchase_receipt, - "account": self.asset_received_but_not_billed, - }, - "name", - ) - if arbnb_booked_in_pr: - account = self.asset_received_but_not_billed - - if not account: - account_type = ( - "capital_work_in_progress_account" - if is_cwip_accounting_enabled(item.asset_category) - else "fixed_asset_account" - ) - account = get_asset_category_account( - account_type, item=item.item_code, company=self.company - ) - if not account: - form_link = get_link_to_form("Asset Category", item.asset_category) - throw( - _("Please set Fixed Asset Account in {} against {}.").format( - form_link, self.company - ), - title=_("Missing Account"), - ) - item.expense_account = account - elif not item.expense_account and for_validate: - throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) - - def validate_expense_account(self): - for item in self.get("items"): - validate_account_head(item.idx, item.expense_account, self.company, _("Expense")) - - def set_against_expense_account(self, force=False): - against_accounts = [] - for item in self.get("items"): - if item.expense_account and (item.expense_account not in against_accounts): - against_accounts.append(item.expense_account) - - self.against_expense_account = ",".join(against_accounts) + ExpenseAccountService(self).set_expense_account(for_validate) def force_set_against_expense_account(self): - self.set_against_expense_account() - frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account) + ExpenseAccountService(self).force_set_against_expense_account() def po_required(self): if ( diff --git a/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py b/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py new file mode 100644 index 00000000000..9f2d949d9a7 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py @@ -0,0 +1,183 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Expense account resolution for Purchase Invoice items.""" + +import frappe +from frappe import _, throw +from frappe.utils import get_link_to_form + +import erpnext +from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled +from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account +from erpnext.controllers.accounts_controller import validate_account_head + + +class ExpenseAccountService: + def __init__(self, doc): + self.doc = doc + + def set_expense_account(self, for_validate: bool = False) -> None: + doc = self.doc + auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company) + + if auto_accounting_for_stock: + stock_not_billed_account = doc.get_company_default("stock_received_but_not_billed") + stock_items = doc.get_stock_items() + + doc.asset_received_but_not_billed = None + + inventory_account_map = {} + if doc.update_stock: + doc.validate_item_code() + doc.validate_warehouse(for_validate) + if auto_accounting_for_stock: + inventory_account_map = doc.get_inventory_account_map() + + for item in doc.get("items"): + # in case of auto inventory accounting, + # expense account is always "Stock Received But Not Billed" for a stock item + # except opening entry, drop-ship entry and fixed asset items + if ( + auto_accounting_for_stock + and item.item_code in stock_items + and doc.is_opening == "No" + and not item.is_fixed_asset + and ( + not item.po_detail + or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier") + ) + ): + if doc.update_stock and item.warehouse and (not item.from_warehouse): + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]: + msg = _( + "Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account" + ).format( + item.idx, + frappe.bold(_inv_dict["account"]), + frappe.bold(item.expense_account), + frappe.bold(item.warehouse), + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + item.expense_account = _inv_dict["account"] + else: + # check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not + if item.purchase_receipt: + negative_expense_booked_in_pr = frappe.db.sql( + """select name from `tabGL Entry` + where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""", + (item.purchase_receipt, stock_not_billed_account), + ) + + if negative_expense_booked_in_pr: + if ( + for_validate + and item.expense_account + and item.expense_account != stock_not_billed_account + ): + msg = _( + "Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}" + ).format( + item.idx, + frappe.bold(stock_not_billed_account), + frappe.bold(item.purchase_receipt), + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + + item.expense_account = stock_not_billed_account + else: + # If no purchase receipt present then book expense in 'Stock Received But Not Billed' + # This is done in cases when Purchase Invoice is created before Purchase Receipt + if ( + for_validate + and item.expense_account + and item.expense_account != stock_not_billed_account + ): + msg = _( + "Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}." + ).format( + item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code) + ) + msg += "
" + msg += _( + "This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice" + ) + frappe.msgprint(msg, title=_("Expense Head Changed")) + + item.expense_account = stock_not_billed_account + elif item.is_fixed_asset: + account = None + if not item.pr_detail and item.po_detail: + receipt_item = frappe.get_cached_value( + "Purchase Receipt Item", + { + "purchase_order": item.purchase_order, + "purchase_order_item": item.po_detail, + "docstatus": 1, + }, + ["name", "parent"], + as_dict=1, + ) + if receipt_item: + item.pr_detail = receipt_item.name + item.purchase_receipt = receipt_item.parent + + if item.pr_detail: + if not doc.asset_received_but_not_billed: + doc.asset_received_but_not_billed = doc.get_company_default( + "asset_received_but_not_billed" + ) + + # check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not + arbnb_booked_in_pr = frappe.db.get_value( + "GL Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": item.purchase_receipt, + "account": doc.asset_received_but_not_billed, + }, + "name", + ) + if arbnb_booked_in_pr: + account = doc.asset_received_but_not_billed + + if not account: + account_type = ( + "capital_work_in_progress_account" + if is_cwip_accounting_enabled(item.asset_category) + else "fixed_asset_account" + ) + account = get_asset_category_account( + account_type, item=item.item_code, company=doc.company + ) + if not account: + form_link = get_link_to_form("Asset Category", item.asset_category) + throw( + _("Please set Fixed Asset Account in {} against {}.").format( + form_link, doc.company + ), + title=_("Missing Account"), + ) + item.expense_account = account + elif not item.expense_account and for_validate: + throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) + + def validate_expense_account(self) -> None: + for item in self.doc.get("items"): + validate_account_head(item.idx, item.expense_account, self.doc.company, _("Expense")) + + def set_against_expense_account(self) -> None: + doc = self.doc + against_accounts = [] + for item in doc.get("items"): + if item.expense_account and (item.expense_account not in against_accounts): + against_accounts.append(item.expense_account) + + doc.against_expense_account = ",".join(against_accounts) + + def force_set_against_expense_account(self) -> None: + doc = self.doc + self.set_against_expense_account() + frappe.db.set_value(doc.doctype, doc.name, "against_expense_account", doc.against_expense_account)