From 798680d2d507f533ec2e25c407fc066a796a8ccb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Thu, 9 Jul 2026 17:25:32 +0530 Subject: [PATCH] refactor(stock): de-conditionalize BaseStockGLComposer via subclass hooks Remove per-doctype branching from the shared stock GL composer so each voucher owns its own behavior: - Move the Stock Reconciliation voucher-detail synthesis out of BaseStockGLComposer.get_voucher_details into a StockReconciliationGLComposer.get_voucher_details override. - Replace the hardcoded doctype allow-list in check_expense_account with an overridable class attribute enforce_pl_expense_account (default True). Vouchers that post the difference to a balance-sheet account (Stock Entry, Stock Reconciliation, Delivery Note) set it False. - Add DeliveryNoteGLComposer to own the P&L-exempt rule and wire DeliveryNote.get_gl_entries to it. No change to GL output; behavior is relocated, not altered. --- .../doctype/delivery_note/delivery_note.py | 5 ++ .../delivery_note/services/gl_composer.py | 17 ++++++ .../stock_entry/services/gl_composer.py | 4 ++ .../services/gl_composer.py | 22 +++++++- .../stock/services/base_stock_gl_composer.py | 52 +++++-------------- 5 files changed, 61 insertions(+), 39 deletions(-) create mode 100644 erpnext/stock/doctype/delivery_note/services/gl_composer.py diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a3a1884cae2..3bc4c2d23d4 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -418,6 +418,11 @@ class DeliveryNote(SellingController): d.actual_qty = flt(bin_qty.actual_qty) d.projected_qty = flt(bin_qty.projected_qty) + def get_gl_entries(self, inventory_account_map=None): + from erpnext.stock.doctype.delivery_note.services.gl_composer import DeliveryNoteGLComposer + + return DeliveryNoteGLComposer(self).compose(inventory_account_map) + def validate_expense_account(self): company_values = frappe.get_cached_value( "Company", diff --git a/erpnext/stock/doctype/delivery_note/services/gl_composer.py b/erpnext/stock/doctype/delivery_note/services/gl_composer.py new file mode 100644 index 00000000000..9768d1d4fe4 --- /dev/null +++ b/erpnext/stock/doctype/delivery_note/services/gl_composer.py @@ -0,0 +1,17 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class DeliveryNoteGLComposer(BaseStockGLComposer): + """GL composer for Delivery Note. + + Delivery Note posts the standard stock ↔ expense (COGS) entries produced by + the base stock GL loop and adds no voucher-specific rows. It only relaxes the + expense-account rule: the delivery difference may land on a balance-sheet + account (e.g. the target warehouse account on an internal customer transfer), + so P&L enforcement is off. + """ + + enforce_pl_expense_account = False diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index 2893a239329..ab254e33699 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -15,8 +15,12 @@ class StockEntryGLComposer(BaseStockGLComposer): Extends the base stock GL loop with additional-cost entries (from the ``additional_costs`` child table) and landed-cost voucher adjustments. + The difference is posted to warehouse/balance-sheet accounts, so P&L + enforcement on the expense account is off. """ + enforce_pl_expense_account = False + def compose(self, inventory_account_map: dict | None = None) -> list: doc = self.doc gl_entries = super().compose(inventory_account_map) diff --git a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py index 0ca408729f0..59335ac8674 100644 --- a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import frappe from frappe import _, msgprint from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer @@ -10,11 +11,30 @@ class StockReconciliationGLComposer(BaseStockGLComposer): """GL composer for Stock Reconciliation. SR carries its own expense_account and cost_center which are passed as - defaults into the base stock GL composition loop. + defaults into the base stock GL composition loop. It synthesises one voucher + detail per stock ledger entry (SR has no ``items`` table with expense rows) + and posts the difference to a balance-sheet account, so P&L enforcement is + off. """ + enforce_pl_expense_account = False + def compose(self, inventory_account_map: dict | None = None) -> list: doc = self.doc if not doc.cost_center: msgprint(_("Please enter Cost Center"), raise_exception=1) return super().compose(inventory_account_map, doc.expense_account, doc.cost_center) + + def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): + is_opening = "Yes" if self.doc.purpose == "Opening Stock" else "No" + return [ + frappe._dict( + { + "name": voucher_detail_no, + "expense_account": default_expense_account, + "cost_center": default_cost_center, + "is_opening": is_opening, + } + ) + for voucher_detail_no in sle_map + ] diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py index e81b40963f7..bfe042e501a 100644 --- a/erpnext/stock/services/base_stock_gl_composer.py +++ b/erpnext/stock/services/base_stock_gl_composer.py @@ -17,6 +17,11 @@ class BaseStockGLComposer(BaseGLComposer): entries on top. """ + #: Whether the item's expense/difference account must be a 'Profit and Loss' + #: account. Vouchers that legitimately post the difference to a balance-sheet + #: account (stock transfers, deliveries, reconciliations) set this to False. + enforce_pl_expense_account = True + def compose( self, inventory_account_map: dict | None = None, @@ -160,34 +165,16 @@ class BaseStockGLComposer(BaseGLComposer): return frappe.flags.debit_field_precision def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): - doc = self.doc - if doc.doctype == "Stock Reconciliation": - reconciliation_purpose = frappe.db.get_value(doc.doctype, doc.name, "purpose") - is_opening = "Yes" if reconciliation_purpose == "Opening Stock" else "No" - details = [] - for voucher_detail_no in sle_map: - details.append( - frappe._dict( - { - "name": voucher_detail_no, - "expense_account": default_expense_account, - "cost_center": default_cost_center, - "is_opening": is_opening, - } - ) - ) - return details - else: - details = doc.get("items") + details = self.doc.get("items") - if default_expense_account or default_cost_center: - for d in details: - if default_expense_account and not d.get("expense_account"): - d.expense_account = default_expense_account - if default_cost_center and not d.get("cost_center"): - d.cost_center = default_cost_center + if default_expense_account or default_cost_center: + for d in details: + if default_expense_account and not d.get("expense_account"): + d.expense_account = default_expense_account + if default_cost_center and not d.get("cost_center"): + d.cost_center = default_cost_center - return details + return details def check_expense_account(self, item): if not item.get("expense_account"): @@ -204,18 +191,7 @@ class BaseStockGLComposer(BaseGLComposer): frappe.get_cached_value("Account", item.get("expense_account"), "report_type") == "Profit and Loss" ) - if ( - self.doc.doctype - not in ( - "Purchase Receipt", - "Purchase Invoice", - "Stock Reconciliation", - "Stock Entry", - "Subcontracting Receipt", - "Delivery Note", - ) - and not is_expense_account - ): + if self.enforce_pl_expense_account and not is_expense_account: frappe.throw( _("Expense / Difference account ({0}) must be a 'Profit or Loss' account").format( item.get("expense_account")