From 6949f23c7fbdc03f36aae027ba2f15645e3ad231 Mon Sep 17 00:00:00 2001 From: Dipen Gala Date: Fri, 12 Jun 2026 12:57:58 +0530 Subject: [PATCH] fix: remove unnecessary expense head warning for purchase invoices with update stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Purchase Invoice is created with `update_stock = 1`, the system automatically replaces the item's expense account with the correct inventory account for perpetual inventory. This is expected behaviour, but a `frappe.msgprint` warning was being shown to the user: "Expense Head changed to Stock In Hand because account Cost of Goods Sold is not linked to warehouse Stores or it is not the default inventory account." The message is purely informational, provides no actionable guidance, and confuses users who deliberately enable Update Stock. The underlying account substitution logic is unchanged; only the popup is suppressed. The two other `msgprint` calls (for the Purchase-Receipt-linked and no-Purchase-Receipt flows) are intentionally preserved — those surface a genuine change in behaviour that users may not expect. Fixes: https://github.com/frappe/erpnext/issues/... (cherry picked from commit 224dff32df57a5520da5b14885df55e46624112d) # Conflicts: # erpnext/accounts/doctype/purchase_invoice/services/expense_account.py --- .../services/expense_account.py | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/expense_account.py 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)