From 6467f074598697a7fa2c195051181f33b34d10cb Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Wed, 27 May 2026 08:13:10 +0530 Subject: [PATCH] refactor: introduce Purchase Invoice GL composer Phase 3 of the accounts/controller refactor. Adds PurchaseInvoiceGLComposer; PI's get_gl_entries body moves into compose() and the method becomes a thin shim. Row-builder methods still live on the document (invoked via self.doc) and migrate onto the composer next. After comparing the SI and PI compose() flows, BaseGLComposer is kept minimal: the two differ in step order, builders, and per-doctype regional function, so a shared template is not warranted. No behavior change (Phase 0 snapshots and PI GL tests stay green). --- .../purchase_invoice/purchase_invoice.py | 31 ++---------- .../purchase_invoice/services/__init__.py | 0 .../purchase_invoice/services/gl_composer.py | 48 +++++++++++++++++++ specs/accounts_refactor_spec.md | 4 +- 4 files changed, 54 insertions(+), 29 deletions(-) create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/__init__.py create mode 100644 erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 8b417584e35..8941f5191e7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -856,34 +856,11 @@ class PurchaseInvoice(BuyingController): ) def get_gl_entries(self, inventory_account_map=None): - self.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company) + from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import ( + PurchaseInvoiceGLComposer, + ) - if self.auto_accounting_for_stock: - self.stock_received_but_not_billed = self.get_company_default("stock_received_but_not_billed") - else: - self.stock_received_but_not_billed = None - - self.negative_expense_to_be_booked = 0.0 - gl_entries = [] - - self.make_supplier_gl_entry(gl_entries) - self.make_item_gl_entries(gl_entries) - self.make_precision_loss_gl_entry(gl_entries) - - self.make_tax_gl_entries(gl_entries) - self.make_internal_transfer_gl_entries(gl_entries) - self.make_gl_entries_for_tax_withholding(gl_entries) - - gl_entries = make_regional_gl_entries(gl_entries, self) - - gl_entries = merge_similar_entries(gl_entries) - - self.make_payment_gl_entries(gl_entries) - self.make_write_off_gl_entry(gl_entries) - self.make_gle_for_rounding_adjustment(gl_entries) - self.set_transaction_currency_and_rate_in_gl_map(gl_entries) - self.set_gl_entry_for_purchase_expense(gl_entries) - return gl_entries + return PurchaseInvoiceGLComposer(self).compose(inventory_account_map) def check_asset_cwip_enabled(self): # Check if there exists any item with cwip accounting enabled in it's asset category diff --git a/erpnext/accounts/doctype/purchase_invoice/services/__init__.py b/erpnext/accounts/doctype/purchase_invoice/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py new file mode 100644 index 00000000000..5992cd9bae6 --- /dev/null +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -0,0 +1,48 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import erpnext +from erpnext.accounts.services.base_gl_composer import BaseGLComposer + + +class PurchaseInvoiceGLComposer(BaseGLComposer): + """Assembles the GL entries for a Purchase Invoice. + + Orchestration only for now: the voucher-specific row builders still live on + the Purchase Invoice document and are invoked via ``self.doc``. They migrate + onto this composer in a later increment. + """ + + def compose(self, inventory_account_map=None): + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries + from erpnext.accounts.general_ledger import merge_similar_entries + + doc = self.doc + doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company) + + if doc.auto_accounting_for_stock: + doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed") + else: + doc.stock_received_but_not_billed = None + + doc.negative_expense_to_be_booked = 0.0 + gl_entries = [] + + doc.make_supplier_gl_entry(gl_entries) + doc.make_item_gl_entries(gl_entries) + doc.make_precision_loss_gl_entry(gl_entries) + + doc.make_tax_gl_entries(gl_entries) + doc.make_internal_transfer_gl_entries(gl_entries) + doc.make_gl_entries_for_tax_withholding(gl_entries) + + gl_entries = make_regional_gl_entries(gl_entries, doc) + + gl_entries = merge_similar_entries(gl_entries) + + doc.make_payment_gl_entries(gl_entries) + doc.make_write_off_gl_entry(gl_entries) + doc.make_gle_for_rounding_adjustment(gl_entries) + doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) + doc.set_gl_entry_for_purchase_expense(gl_entries) + return gl_entries diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 4922e1bbd22..7d0eac8c650 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -74,8 +74,8 @@ Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validato ### Phase 2 — Pilot composer on Sales Invoice only — DONE Added `BaseGLComposer` (minimal: holds `self.doc`) and `SalesInvoiceGLComposer`. SI's `get_gl_entries` is a thin shim delegating to `SalesInvoiceGLComposer(self).compose()`. All 11 SI-specific row builders (make_customer/tax/item/internal_transfer/pos/loyalty/write_off/rounding GL entries, stock_delivered_but_not_billed, get_gl_entries_for_fixed_asset, get_gle_for_change_amount) moved onto the composer and operate on `self.doc`. The `super().get_gl_entries()` stock-expense call became `super(SalesInvoice, doc).get_gl_entries()` (MRO-faithful). Bucket-A shared helpers (`get_gl_dict`, `make_discount_gl_entries`, `make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_tax_amounts`, `get_amount_and_base_amount`) **stay on the controller** — they're still called via `self.doc` and only lift to `BaseGLComposer` once all doctypes use composers (can't move while other doctypes inherit them). Verified: 12 snapshots + 10 existing SI tests (perpetual `super()`, POS change, write-off, returns, fixed-asset disposal/regain, internal transfer, loyalty) all green. -### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) -Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction. +### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) — IN PROGRESS +Added `PurchaseInvoiceGLComposer` (scaffolding: compose() = the moved get_gl_entries orchestration; PI.get_gl_entries is a thin shim). **Decision after comparing SI and PI: keep `BaseGLComposer` minimal** (`self.doc` + abstract `compose`). The two flows differ too much to share a template — different step order (SI tax→item, PI item→tax), different builders (SI: discount/loyalty/POS/SDBNB; PI: tax-withholding/payment/purchase-expense), and a per-doctype `make_regional_gl_entries`. Forcing a template would be hook-heavy and risk behavior changes. Revisit base-lifting only when a 3rd+ doctype reveals a real common shape. Verified: 12 snapshots + 6 existing PI GL tests (perpetual inventory, non-stock, return, update_stock, tax withholding, provisional) green. (PI row-builder method migration onto the composer, mirroring SI, still pending.) ### Phase 4 — Roll out composer to remaining GL-posting doctypes Payment Entry, Journal Entry, Delivery Note, Stock Entry, etc. Mechanical now; one PR per doctype (or small batches), each snapshot-gated.