From 918e5a28db0a37b115a13f7b3636de57d1392969 Mon Sep 17 00:00:00 2001 From: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:27:08 +0530 Subject: [PATCH] =?UTF-8?q?fix(stock):=20carry=20accounting=20dimensions?= =?UTF-8?q?=20from=20Landed=20Cost=20Voucher=20char=E2=80=A6=20(#56981)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(stock): carry accounting dimensions from Landed Cost Voucher charges into GL entries * feat(stock): add accounting dimension fields to Landed Cost Taxes and Charges The charge row had no dimension fields, so a dimension marked mandatory for Profit and Loss accounts could not be supplied anywhere on the voucher. Add the accounting dimensions section, cost center and project, and register the doctype in accounting_dimension_doctypes so custom dimension fields are created on it. The section and column break are required for that hook to place the generated fields correctly. Cost center deliberately omits the ":Company" default used by Purchase Taxes and Charges: this child table is also the additional costs table on Stock Entry and Subcontracting Receipt, and auto-filling it there would change existing postings. * refactor(stock): group landed cost charges by expense account and dimensions get_item_account_wise_lcv_entries keyed its inner map by expense account alone, so two charge rows posting to the same account - whether in one voucher or across vouchers - were merged. Amounts accumulated correctly but any per-row context was lost to whichever row was seen first. Key the grouping by (expense account, dimension values) and return a list of charges per receipt item, each carrying its own dimensions, so rows that differ only by dimension stay distinct. Dimensions resolve from the charge row first, then the voucher item row. Blanks are left blank so the GL composers can fall back to the receipt item and receipt document as before. * refactor(accounts): allow explicit accounting dimensions on add_gl_entry get_gl_dict derives dimensions from the parent document and the item row, and reads only custom dimensions off the item - never cost center or project. Callers that need to set a dimension from some other source had no way to do so except by building the args dict by hand. Add a dimensions argument that is merged into the entry before get_gl_dict is called, and thread it through the StockController and BaseGLComposer wrappers. * fix(stock): carry landed cost charge dimensions onto the GL entries Landed cost charges are posted into the receipt document's ledger, and their expense account is a Profit and Loss account. Until now the entry took its dimensions from the receipt item, which cannot know about a voucher created after it was submitted, so a dimension mandatory for P&L accounts failed. Take cost center, project and custom dimensions from the charge row, falling back to the receipt item and receipt document when the row leaves them blank. Only the leg posting to the charge account is affected; the reclass leg keeps the item's dimensions so it still nets against the base item entry. Also skip charges that prorate to zero, and hoist the landed cost lookup in the Purchase Receipt composer out of the item loop - it was reloading every voucher once per item. * fix(stock): report missing mandatory dimensions on the Landed Cost Voucher row Submitting a voucher re-makes the receipt document's GL entries, so a missing mandatory dimension surfaced as a GL Entry error naming an account, raised from the middle of update_landed_cost, with nothing pointing at the row that caused it. Check the charge rows during validate instead, against both the mandatory for P&L / Balance Sheet flags and the per-account Accounting Dimension Filter, and name the row, the dimension and the account in the message. The check resolves values through the same fallback chain the GL composers use, so it does not reject a voucher that would have posted successfully. * test(stock): cover accounting dimensions on landed cost vouchers Covers the charge row reaching the GL entry, cost center and project overriding the receipt item, the blank row still falling back to it, and two charge rows - and two vouchers - on the same expense account with different dimensions staying separate entries. Also covers the mandatory P&L dimension being satisfied from the charge row, the missing one being reported on the voucher, dimensions surviving a repost, and each dimension netting to zero on cancellation. * refactor(lcv): apply custom dimension overrides via .update() --------- Co-authored-by: nareshkannasln Co-authored-by: rohitwaghchaure --- .../purchase_invoice/services/gl_composer.py | 41 +-- erpnext/accounts/services/base_gl_composer.py | 12 +- erpnext/controllers/stock_controller.py | 2 + erpnext/hooks.py | 1 + erpnext/patches.txt | 1 + ...nsions_in_landed_cost_taxes_and_charges.py | 11 + .../landed_cost_taxes_and_charges.json | 29 +- .../landed_cost_voucher.py | 183 ++++++++++- .../test_landed_cost_voucher.py | 286 ++++++++++++++++++ .../purchase_receipt/services/gl_composer.py | 59 ++-- .../stock_entry/services/gl_composer.py | 90 +++--- .../services/gl_composer.py | 85 +++--- 12 files changed, 663 insertions(+), 137 deletions(-) create mode 100644 erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py index fc030ebf4d3..fa68cee9931 100644 --- a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py @@ -130,6 +130,9 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( get_purchase_document_details, ) + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) doc = self.doc tax_service = TaxService(doc) @@ -270,25 +273,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer): # Amount added through landed-cost-voucher if landed_cost_entries: - if (item.item_code, item.name) in landed_cost_entries: - for account, base_amount in landed_cost_entries[ - (item.item_code, item.name) - ].items(): - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": item.expense_account, - "cost_center": item.cost_center, - "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), - "credit": flt(base_amount["base_amount"]), - "credit_in_account_currency": flt(base_amount["amount"]), - "credit_in_transaction_currency": item.net_amount, - "project": item.project or doc.project, - }, - item=item, - ) - ) + for entry in landed_cost_entries.get((item.item_code, item.name), []): + if not (entry.amount or entry.base_amount): + continue + + gl_dict = self.get_gl_dict( + { + "account": entry.expense_account, + "against": item.expense_account, + "cost_center": entry.dimensions.cost_center or item.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit": flt(entry.base_amount), + "credit_in_account_currency": flt(entry.amount), + "credit_in_transaction_currency": item.net_amount, + "project": entry.dimensions.project or item.project or doc.project, + }, + item=item, + ) + gl_dict.update(get_custom_dimension_overrides(entry)) + gl_entries.append(gl_dict) # sub-contracting warehouse if flt(item.rm_supp_cost): diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py index 1ec53d07e44..b2050125fe9 100644 --- a/erpnext/accounts/services/base_gl_composer.py +++ b/erpnext/accounts/services/base_gl_composer.py @@ -142,8 +142,13 @@ def add_gl_entry( voucher_detail_no: str | None = None, item=None, posting_date=None, + dimensions: dict | None = None, ) -> None: - """Build a GL entry via get_gl_dict and append it to gl_entries.""" + """Build a GL entry via get_gl_dict and append it to gl_entries. + + `dimensions` sets accounting dimensions explicitly, overriding the values `get_gl_dict` + would otherwise derive from `item` and the parent document. + """ gl_entry = { "account": account, "cost_center": cost_center, @@ -168,6 +173,9 @@ def add_gl_entry( if posting_date: gl_entry["posting_date"] = posting_date + if dimensions: + gl_entry.update(dimensions) + gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) @@ -255,6 +263,7 @@ class BaseGLComposer: voucher_detail_no: str | None = None, item=None, posting_date=None, + dimensions: dict | None = None, ) -> None: add_gl_entry( self.doc, @@ -272,4 +281,5 @@ class BaseGLComposer: voucher_detail_no, item, posting_date, + dimensions, ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f5723bec5de..1d54d3b9679 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -434,6 +434,7 @@ class StockController(AccountsController): voucher_detail_no=None, item=None, posting_date=None, + dimensions=None, ): from erpnext.accounts.services.base_gl_composer import add_gl_entry @@ -453,6 +454,7 @@ class StockController(AccountsController): voucher_detail_no, item, posting_date, + dimensions, ) def update_stock_reservation_entries(self): diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 878ab21e8fc..51ccc25d50d 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -595,6 +595,7 @@ accounting_dimension_doctypes = [ "Purchase Taxes and Charges", "Shipping Rule", "Landed Cost Item", + "Landed Cost Taxes and Charges", "Asset Value Adjustment", "Asset Repair", "Asset Capitalization", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d5eb474d62e..aff0e29690e 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -498,6 +498,7 @@ erpnext.patches.v16_0.create_shop_floor_roles erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm +erpnext.patches.v16_0.create_accounting_dimensions_in_landed_cost_taxes_and_charges erpnext.patches.v16_0.access_control_for_project_users erpnext.patches.v16_0.enable_book_stock_expense_gl_entries execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0) diff --git a/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py new file mode 100644 index 00000000000..4fa19fac744 --- /dev/null +++ b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py @@ -0,0 +1,11 @@ +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_dimensions, + make_dimension_in_accounting_doctypes, +) + + +def execute(): + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + make_dimension_in_accounting_doctypes(dimension, ["Landed Cost Taxes and Charges"]) diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index 6d638b6e59b..5e13fec040e 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -17,7 +17,11 @@ "has_operating_cost", "operation_id", "qty", - "operating_component" + "operating_component", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project" ], "fields": [ { @@ -107,13 +111,34 @@ "label": "Operating Component", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-19 12:21:07.953801", + "modified": "2026-08-04 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 17543143cb3..53b48e58314 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -91,6 +91,8 @@ class LandedCostVoucher(Document): self.set_applicable_charges_on_item() self.set_total_vendor_invoices_cost() + # Runs last: needs the items table populated by get_items_from_purchase_receipts + self.validate_mandatory_dimensions() def set_total_vendor_invoices_cost(self): self.total_vendor_invoices_cost = 0.0 @@ -201,6 +203,104 @@ class LandedCostVoucher(Document): exc=IncorrectCompanyValidationError, ) + def validate_mandatory_dimensions(self): + """Flag missing mandatory dimensions on the charge row that causes them. + + The landed cost charges are posted as part of the *receipt document's* ledger, so + without this the user sees a GL Entry error raised from the middle of + `update_landed_cost`, naming an account but not the voucher row responsible. + """ + from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + get_checks_for_pl_and_bs_accounts, + ) + from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import ( + get_dimension_filter_map, + ) + + if not is_perpetual_inventory_enabled(self.company): + return + + company_checks = [ + check + for check in get_checks_for_pl_and_bs_accounts() + if check.company == self.company and (check.mandatory_for_pl or check.mandatory_for_bs) + ] + dimension_filter_map = get_dimension_filter_map() + + if not company_checks and not dimension_filter_map: + return + + labels = {d.fieldname: d.label for d in get_accounting_dimensions(as_list=False)} + receipts = {} + + for tax in self.get("taxes"): + if not tax.expense_account: + continue + + report_type = frappe.get_cached_value("Account", tax.expense_account, "report_type") + + mandatory = {} + for check in company_checks: + is_mandatory = ( + check.mandatory_for_pl if report_type == "Profit and Loss" else check.mandatory_for_bs + ) + if is_mandatory: + mandatory[check.fieldname] = check.label + + for (fieldname, account), dimension_filter in dimension_filter_map.items(): + if account == tax.expense_account and dimension_filter.get("is_mandatory"): + mandatory.setdefault(fieldname, labels.get(fieldname) or frappe.unscrub(fieldname)) + + for fieldname, label in mandatory.items(): + if tax.get(fieldname): + continue + + for item in self.get("items"): + if self.get_receipt_dimension(receipts, item, fieldname): + continue + + frappe.throw( + _( + "Row {0}: Accounting Dimension {1} is mandatory for account {2}." + " Set it on this Taxes and Charges row, or on Item Row {3} ({4})." + ).format( + tax.idx, + frappe.bold(label), + frappe.bold(tax.expense_account), + item.idx, + frappe.bold(item.item_code), + ), + title=_("Missing Accounting Dimension"), + ) + + def get_receipt_dimension(self, receipts, item, fieldname): + """Resolve a dimension the way the GL composers do, minus the charge row itself. + + Mirrors the composer fallback chain: LCV item row, then the receipt item row, then + the receipt document. Keep the two in step - if they disagree, this either blocks a + voucher that would have posted fine or lets one through that still fails downstream. + """ + if item.get(fieldname): + return item.get(fieldname) + + key = (item.receipt_document_type, item.receipt_document) + if key not in receipts: + receipts[key] = frappe.get_doc(*key) if item.receipt_document else None + + receipt = receipts[key] + if not receipt: + return None + + row_fieldname = "stock_entry_item" if receipt.doctype == "Stock Entry" else "purchase_receipt_item" + receipt_row_name = item.get(row_fieldname) + + for row in receipt.get("items") or []: + if row.name == receipt_row_name and row.get(fieldname): + return row.get(fieldname) + + return receipt.get(fieldname) + def set_total_taxes_and_charges(self): self.total_taxes_and_charges = sum(flt(d.base_amount) for d in self.get("taxes")) @@ -559,8 +659,55 @@ def has_landed_cost_amount(doc): return False +def get_lcv_dimension_fields(): + """Every field whose value should travel from an LCV row onto the landed cost GL entry. + + `get_accounting_dimensions()` covers custom dimensions only, so cost center and project + are prepended explicitly. + """ + from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + ) + + return ["cost_center", "project", *get_accounting_dimensions()] + + +def get_row_dimensions(tax_row, lcv_item, dimension_fields): + """Resolve the dimensions of a landed cost charge: tax row first, then the LCV item row. + + Blanks are left blank on purpose - the GL composers fall back to the receipt item and + then the receipt document from there. + """ + return frappe._dict( + {field: (tax_row.get(field) or lcv_item.get(field) or None) for field in dimension_fields} + ) + + +def get_custom_dimension_overrides(entry): + """Custom dimension overrides for a landed cost GL entry. + + Cost center and project are excluded because the composers pass them as explicit + arguments. Only truthy values are returned: `get_gl_dict` applies `args` last, so a + `None` here would wipe out the receipt item fallback instead of deferring to it. + """ + return { + dimension: value + for dimension, value in (entry.dimensions or {}).items() + if value and dimension not in ("cost_center", "project") + } + + def get_item_account_wise_lcv_entries(doc): - """Account-wise landed-cost map for a receipt document, consumed by the GL composers.""" + """Landed cost charges for a receipt document, consumed by the GL composers. + + Returns `{(item_code, receipt_row_name): [entry, ...]}` where each entry is a + `frappe._dict(expense_account, amount, base_amount, dimensions)`. + + Charges are grouped by *(expense account, dimension values)* rather than by expense + account alone, so two tax rows - whether in one voucher or across vouchers - that post + to the same account with different dimensions stay separate GL entries instead of + silently collapsing into the first row's dimensions. + """ if not has_landed_cost_amount(doc): return @@ -574,6 +721,7 @@ def get_item_account_wise_lcv_entries(doc): return item_account_wise_cost = {} + dimension_fields = get_lcv_dimension_fields() row_fieldname = "purchase_receipt_item" if doc.doctype == "Stock Entry": @@ -595,25 +743,36 @@ def get_item_account_wise_lcv_entries(doc): for item in landed_cost_voucher_doc.items: if item.receipt_document == doc.name: + charges = item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {}) + for account in landed_cost_voucher_doc.taxes: exchange_rate = account.exchange_rate or 1 - item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {}) - item_account_wise_cost[(item.item_code, item.get(row_fieldname))].setdefault( - account.expense_account, {"amount": 0.0, "base_amount": 0.0} + dimensions = get_row_dimensions(account, item, dimension_fields) + group_key = ( + account.expense_account, + tuple(dimensions.get(field) for field in dimension_fields), ) - item_row = item_account_wise_cost[(item.item_code, item.get(row_fieldname))][ - account.expense_account - ] + item_row = charges.get(group_key) + if item_row is None: + item_row = charges[group_key] = frappe._dict( + expense_account=account.expense_account, + amount=0.0, + base_amount=0.0, + dimensions=dimensions, + ) if total_item_cost > 0: - item_row["amount"] += account.amount * item.get(based_on_field) / total_item_cost + item_row.amount += account.amount * item.get(based_on_field) / total_item_cost - item_row["base_amount"] += ( + item_row.base_amount += ( account.base_amount * item.get(based_on_field) / total_item_cost ) else: - item_row["amount"] += item.applicable_charges / exchange_rate - item_row["base_amount"] += item.applicable_charges + # Pre-existing behaviour: this adds the item's full applicable charges once + # per tax row. Unreachable for submitted vouchers, since + # validate_applicable_charges_for_item rejects a zero total. + item_row.amount += item.applicable_charges / exchange_rate + item_row.base_amount += item.applicable_charges - return item_account_wise_cost + return {key: list(charges.values()) for key, charges in item_account_wise_cost.items()} diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index bc776483eba..47b1d538a42 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -1429,3 +1429,289 @@ def distribute_landed_cost_on_items(lcv): for item in lcv.get("items"): item.applicable_charges = flt(item.get(based_on)) * flt(lcv.total_taxes_and_charges) / flt(total) item.applicable_charges = flt(item.applicable_charges, lcv.precision("applicable_charges", item)) + + +def ensure_dimension_fields_on_lcv_charges(dimensions): + """Create the dimension custom fields the hooks entry and patch add on migrate. + + Test sites are not guaranteed to have migrated since `Landed Cost Taxes and Charges` + joined `accounting_dimension_doctypes`. + """ + from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + make_dimension_in_accounting_doctypes, + ) + + created = False + + for name in dimensions: + dimension = frappe.get_doc("Accounting Dimension", name) + if frappe.db.exists( + "Custom Field", {"dt": "Landed Cost Taxes and Charges", "fieldname": dimension.fieldname} + ): + continue + + make_dimension_in_accounting_doctypes(dimension, ["Landed Cost Taxes and Charges"]) + created = True + + if created: + frappe.clear_cache(doctype="Landed Cost Taxes and Charges") + + +def create_branch(branch): + if not frappe.db.exists("Branch", branch): + frappe.get_doc({"doctype": "Branch", "branch": branch}).insert() + + return branch + + +class TestLandedCostVoucherAccountingDimensions(ERPNextTestSuite): + """Dimensions set on a Landed Cost Voucher charge row must reach the GL entries. + + The charges are posted into the *receipt document's* ledger, and their expense account + (`Expenses Included In Valuation`) is a Profit and Loss account. A dimension marked + mandatory for P&L accounts can therefore only be satisfied from the voucher - the + receipt was submitted before the voucher existed and knows nothing about it. + """ + + def setUp(self): + self.company = "_Test Company with perpetual inventory" + self.warehouse = "Stores - TCP1" + self.expense_account = get_expense_account(self.company) + + ensure_dimension_fields_on_lcv_charges(["Branch"]) + self.branch_a = create_branch("_Test LCV Branch A") + self.branch_b = create_branch("_Test LCV Branch B") + + # helpers + + def make_lcv(self, pr, charges, do_not_submit=False): + lcv = frappe.new_doc("Landed Cost Voucher") + lcv.company = self.company + lcv.distribute_charges_based_on = "Amount" + lcv.set( + "purchase_receipts", + [ + { + "receipt_document_type": "Purchase Receipt", + "receipt_document": pr.name, + "supplier": pr.supplier, + "posting_date": pr.posting_date, + "grand_total": pr.base_grand_total, + } + ], + ) + + for idx, charge in enumerate(charges): + lcv.append( + "taxes", + { + "description": f"_Test Charge {idx + 1}", + "expense_account": charge.pop("expense_account", self.expense_account), + **charge, + }, + ) + + lcv.insert() + + if not do_not_submit: + lcv.submit() + + return lcv + + def get_lcv_gl_entries(self, pr, account=None): + return frappe.get_all( + "GL Entry", + filters={ + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "is_cancelled": 0, + **({"account": account} if account else {}), + }, + fields=["account", "debit", "credit", "cost_center", "project", "branch"], + order_by="credit desc", + ) + + def make_dimension_mandatory(self, name, mandatory_for_pl=0, mandatory_for_bs=0): + """Flag a dimension mandatory for this company, restoring the record afterwards. + + Leaving a dimension mandatory leaks into every later test in the run. + """ + dimension = frappe.get_doc("Accounting Dimension", name) + row = next((d for d in dimension.dimension_defaults if d.company == self.company), None) + + if row: + previous = (row.mandatory_for_pl, row.mandatory_for_bs) + self.addCleanup(self.restore_dimension_default, name, previous) + else: + row = dimension.append( + "dimension_defaults", + {"company": self.company, "reference_document": dimension.document_type}, + ) + self.addCleanup(self.remove_dimension_default, name) + + row.mandatory_for_pl = mandatory_for_pl + row.mandatory_for_bs = mandatory_for_bs + dimension.save() + + def restore_dimension_default(self, name, previous): + dimension = frappe.get_doc("Accounting Dimension", name) + for row in dimension.dimension_defaults: + if row.company == self.company: + row.mandatory_for_pl, row.mandatory_for_bs = previous + dimension.save() + + def remove_dimension_default(self, name): + dimension = frappe.get_doc("Accounting Dimension", name) + dimension.set( + "dimension_defaults", + [d for d in dimension.dimension_defaults if d.company != self.company], + ) + dimension.save() + + # tests + + def test_charge_row_dimension_reaches_gl_entry(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_lcv(pr, [{"amount": 100, "branch": self.branch_a}]) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 1) + self.assertEqual(charge_entries[0].credit, 100.0) + self.assertEqual(charge_entries[0].branch, self.branch_a) + + # the stock leg is untouched - it keeps the receipt item's dimensions + stock_account = get_inventory_account(self.company, self.warehouse) + self.assertFalse(self.get_lcv_gl_entries(pr, stock_account)[0].branch) + + def test_charge_row_cost_center_and_project_override_receipt_item(self): + from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center + + create_cost_center( + cost_center_name="_Test LCV Cost Center", + company=self.company, + parent_cost_center=f"{self.company} - TCP1", + ) + cost_center = "_Test LCV Cost Center - TCP1" + + if not frappe.db.exists("Project", {"project_name": "_Test LCV Project"}): + frappe.get_doc( + {"doctype": "Project", "project_name": "_Test LCV Project", "company": self.company} + ).insert() + project = frappe.db.get_value("Project", {"project_name": "_Test LCV Project"}) + + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + item_cost_center = pr.items[0].cost_center + + self.make_lcv(pr, [{"amount": 100, "cost_center": cost_center, "project": project}]) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 1) + self.assertEqual(charge_entries[0].cost_center, cost_center) + self.assertEqual(charge_entries[0].project, project) + + # the stock leg still uses the receipt item's cost center + stock_account = get_inventory_account(self.company, self.warehouse) + self.assertEqual(self.get_lcv_gl_entries(pr, stock_account)[0].cost_center, item_cost_center) + + def test_blank_charge_row_falls_back_to_receipt_item(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_lcv(pr, [{"amount": 100}]) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 1) + self.assertEqual(charge_entries[0].cost_center, pr.items[0].cost_center) + self.assertFalse(charge_entries[0].branch) + + def test_charge_rows_on_same_account_with_different_dimensions_stay_separate(self): + """Two charges on one account used to merge, keeping only the first row's dimensions.""" + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_lcv( + pr, + [ + {"amount": 60, "branch": self.branch_a}, + {"amount": 40, "branch": self.branch_b}, + ], + ) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 2) + self.assertEqual( + {(e.branch, e.credit) for e in charge_entries}, + {(self.branch_a, 60.0), (self.branch_b, 40.0)}, + ) + self.assertEqual(sum(e.credit for e in charge_entries), 100.0) + + def test_two_vouchers_on_same_account_with_different_dimensions_stay_separate(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_lcv(pr, [{"amount": 60, "branch": self.branch_a}]) + self.make_lcv(pr, [{"amount": 40, "branch": self.branch_b}]) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 2) + self.assertEqual( + {(e.branch, e.credit) for e in charge_entries}, + {(self.branch_a, 60.0), (self.branch_b, 40.0)}, + ) + + def test_mandatory_pl_dimension_is_satisfied_by_charge_row(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_dimension_mandatory("Branch", mandatory_for_pl=1) + + self.make_lcv(pr, [{"amount": 100, "branch": self.branch_a}]) + + charge_entries = self.get_lcv_gl_entries(pr, self.expense_account) + self.assertEqual(len(charge_entries), 1) + self.assertEqual(charge_entries[0].branch, self.branch_a) + + def test_missing_mandatory_dimension_is_reported_on_the_voucher(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_dimension_mandatory("Branch", mandatory_for_pl=1) + + with self.assertRaises(frappe.ValidationError) as raised: + self.make_lcv(pr, [{"amount": 100}]) + + message = str(raised.exception) + self.assertIn("Branch", message) + self.assertIn(self.expense_account, message) + + def test_dimensions_survive_reposting(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + self.make_lcv( + pr, + [ + {"amount": 60, "branch": self.branch_a}, + {"amount": 40, "branch": self.branch_b}, + ], + ) + + before = {(e.branch, e.credit) for e in self.get_lcv_gl_entries(pr, self.expense_account)} + + items, warehouses = pr.get_items_and_warehouses() + update_gl_entries_after(pr.posting_date, pr.posting_time, warehouses, items, company=pr.company) + + after = {(e.branch, e.credit) for e in self.get_lcv_gl_entries(pr, self.expense_account)} + self.assertEqual(before, after) + + def test_cancelling_the_voucher_nets_each_dimension_to_zero(self): + pr = make_purchase_receipt(company=self.company, warehouse=self.warehouse) + lcv = self.make_lcv( + pr, + [ + {"amount": 60, "branch": self.branch_a}, + {"amount": 40, "branch": self.branch_b}, + ], + ) + + lcv.reload() + lcv.cancel() + + balances = {} + for entry in frappe.get_all( + "GL Entry", + filters={"voucher_no": pr.name, "account": self.expense_account}, + fields=["branch", "debit", "credit"], + ): + balances[entry.branch] = balances.get(entry.branch, 0.0) + entry.debit - entry.credit + + for branch, balance in balances.items(): + self.assertEqual(flt(balance, 2), 0.0, msg=f"branch {branch} does not net to zero") diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py index 61350d78200..55dead0d69d 100644 --- a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py +++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py @@ -41,6 +41,9 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( get_purchase_document_details, ) + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_stock_value_difference doc = self.doc @@ -51,6 +54,7 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): exchange_rate_map, net_rate_map = get_purchase_document_details(doc) stock_items = doc.get_stock_items() warehouse_with_no_account = [] + landed_cost_entries = doc.get_item_account_wise_lcv_entries() def validate_account(account_type): frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type)) @@ -165,32 +169,38 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): return outgoing_amount def make_landed_cost_gl_entries(item): - if item.landed_cost_voucher_amount and landed_cost_entries: - if (item.item_code, item.name) in landed_cost_entries: - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != doc.company_currency) - else flt(amount["amount"]) - ) + if not (item.landed_cost_voucher_amount and landed_cost_entries): + return - if not account: - validate_account("Landed Cost Account") + for entry in landed_cost_entries.get((item.item_code, item.name), []): + if not (entry.amount or entry.base_amount): + continue - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=stock_asset_account_name, - credit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) + account = entry.expense_account + if not account: + validate_account("Landed Cost Account") + + account_currency = get_account_currency(account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=entry.dimensions.cost_center or item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=stock_asset_account_name, + credit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=entry.dimensions.project or item.project, + item=item, + dimensions=get_custom_dimension_overrides(entry), + ) def make_expenses_added_to_stock_entries(item): if not self.book_stock_expense_enabled(): @@ -300,7 +310,6 @@ class PurchaseReceiptGLComposer(BaseStockGLComposer): if d.is_fixed_asset else doc.get_company_default("stock_received_but_not_billed") ) - landed_cost_entries = doc.get_item_account_wise_lcv_entries() if d.is_fixed_asset: stock_asset_account_name = d.expense_account stock_value_diff = ( diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py index 6957a99ca99..6b20416b3b5 100644 --- a/erpnext/stock/doctype/stock_entry/services/gl_composer.py +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -273,6 +273,10 @@ class StockEntryGLComposer(BaseStockGLComposer): ) def _append_lcv_gl_entries(self, gl_entries: list, inventory_account_map: dict) -> None: + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) + doc = self.doc landed_cost_entries = doc.get_item_account_wise_lcv_entries() if not landed_cost_entries: @@ -282,47 +286,51 @@ class StockEntryGLComposer(BaseStockGLComposer): if item.s_warehouse: continue - if (item.item_code, item.name) in landed_cost_entries: - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != doc.company_currency) - else flt(amount["amount"]) - ) + for entry in landed_cost_entries.get((item.item_code, item.name), []): + if not (entry.amount or entry.base_amount): + continue - _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": _inv_dict["account"], - "cost_center": item.cost_center, - "debit": 0.0, - "credit": credit_amount, - "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), - "credit_in_account_currency": flt(amount["amount"]), - "account_currency": account_currency, - "project": item.project, - }, - item=item, - ) - ) + account_currency = get_account_currency(entry.expense_account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) - account_currency = get_account_currency(item.expense_account) - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": _inv_dict["account"], - "cost_center": item.cost_center, - "debit": 0.0, - "credit": credit_amount * -1, - "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), - "debit_in_account_currency": flt(amount["amount"]), - "account_currency": account_currency, - "project": item.project, - }, - item=item, - ) + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") + gl_dict = self.get_gl_dict( + { + "account": entry.expense_account, + "against": _inv_dict["account"], + "cost_center": entry.dimensions.cost_center or item.cost_center, + "debit": 0.0, + "credit": credit_amount, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "credit_in_account_currency": flt(entry.amount), + "account_currency": account_currency, + "project": entry.dimensions.project or item.project, + }, + item=item, + ) + gl_dict.update(get_custom_dimension_overrides(entry)) + gl_entries.append(gl_dict) + + # Reclass leg: keeps the item's dimensions so it nets against the base item entry + # posted to the same expense account. + account_currency = get_account_currency(item.expense_account) + gl_entries.append( + self.get_gl_dict( + { + "account": item.expense_account, + "against": _inv_dict["account"], + "cost_center": item.cost_center, + "debit": 0.0, + "credit": credit_amount * -1, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "debit_in_account_currency": flt(entry.amount), + "account_currency": account_currency, + "project": item.project, + }, + item=item, ) + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py index 7e31454ab23..e1217edb81e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py @@ -214,6 +214,10 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): ) def _make_item_gl_entries_for_lcv(self, gl_entries: list, inventory_account_map: dict | None) -> None: + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) + doc = self.doc landed_cost_entries = doc.get_item_account_wise_lcv_entries() @@ -221,45 +225,52 @@ class SubcontractingReceiptGLComposer(BaseStockGLComposer): return for item in doc.items: - if item.landed_cost_voucher_amount and landed_cost_entries: + item_entries = landed_cost_entries.get((item.item_code, item.name), []) + + if item.landed_cost_voucher_amount and item_entries: remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(doc.name) - if (item.item_code, item.name) in landed_cost_entries: - _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) - for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): - account_currency = get_account_currency(account) - credit_amount = ( - flt(amount["base_amount"]) - if (amount["base_amount"] or account_currency != doc.company_currency) - else flt(amount["amount"]) - ) + for entry in item_entries: + if not (entry.amount or entry.base_amount): + continue - self.add_gl_entry( - gl_entries=gl_entries, - account=account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount, - remarks=remarks, - against_account=_inv_dict["account"], - credit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) + account_currency = get_account_currency(entry.expense_account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) - account_currency = get_account_currency(item.expense_account) + self.add_gl_entry( + gl_entries=gl_entries, + account=entry.expense_account, + cost_center=entry.dimensions.cost_center or item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=_inv_dict["account"], + credit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=entry.dimensions.project or item.project, + item=item, + dimensions=get_custom_dimension_overrides(entry), + ) - self.add_gl_entry( - gl_entries=gl_entries, - account=item.expense_account, - cost_center=item.cost_center, - debit=0.0, - credit=credit_amount * -1, - remarks=remarks, - against_account=_inv_dict["account"], - debit_in_account_currency=flt(amount["amount"]), - account_currency=account_currency, - project=item.project, - item=item, - ) + # Reclass leg: keeps the item's dimensions so it nets against the base item + # entry posted to the same expense account. + account_currency = get_account_currency(item.expense_account) + + self.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount * -1, + remarks=remarks, + against_account=_inv_dict["account"], + debit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=item.project, + item=item, + )