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..df03a9a838f --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/expense_account.py @@ -0,0 +1,173 @@ +# 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) + + 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)