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.
This commit is contained in:
Nabin Hait
2026-07-09 17:25:32 +05:30
parent 0688cedba2
commit 798680d2d5
5 changed files with 61 additions and 39 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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)

View File

@@ -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
]

View File

@@ -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")