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, + )