fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)

* 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 <nareshkannashanmugam@gmail.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
This commit is contained in:
Vishnu Priya Baskaran
2026-08-26 11:27:08 +05:30
committed by GitHub
parent c940bd1e66
commit 918e5a28db
12 changed files with 663 additions and 137 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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