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>
(cherry picked from commit 918e5a28db)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
#	erpnext/accounts/services/base_gl_composer.py
#	erpnext/controllers/stock_controller.py
#	erpnext/patches.txt
#	erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
#	erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
#	erpnext/stock/doctype/stock_entry/services/gl_composer.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
This commit is contained in:
Vishnu Priya Baskaran
2026-08-26 11:27:08 +05:30
committed by Mergify
parent 7238ecb306
commit 41cee6e71b
12 changed files with 2854 additions and 2 deletions

View File

@@ -0,0 +1,897 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import cint, flt, get_link_to_form
import erpnext
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.services.taxes import TaxService
from erpnext.accounts.utils import get_account_currency
class PurchaseInvoiceGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Purchase Invoice."""
def compose(self, inventory_account_map=None):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries
from erpnext.accounts.general_ledger import merge_similar_entries
doc = self.doc
doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
if doc.auto_accounting_for_stock:
doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed")
else:
doc.stock_received_but_not_billed = None
doc.negative_expense_to_be_booked = 0.0
gl_entries = []
self.make_supplier_gl_entry(gl_entries)
self.make_item_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_gl_entries_for_tax_withholding(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, doc)
gl_entries = merge_similar_entries(gl_entries)
self.make_payment_gl_entries(gl_entries)
self.make_write_off_gl_entry(gl_entries)
self.make_gle_for_rounding_adjustment(gl_entries)
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
doc.set_gl_entry_for_purchase_expense(gl_entries)
return gl_entries
def make_precision_loss_gl_entry(self, gl_entries):
doc = self.doc
(
round_off_account,
round_off_cost_center,
_round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
)
precision_loss = doc.get("base_net_total") - flt(
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
)
if precision_loss:
gl_entries.append(
doc.get_gl_dict(
{
"account": round_off_account,
"against": doc.supplier,
"credit": precision_loss,
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else doc.cost_center or round_off_cost_center,
"remarks": _("Net total calculation precision loss"),
}
)
)
def make_supplier_gl_entry(self, gl_entries):
doc = self.doc
grand_total = (
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
)
base_grand_total = flt(
doc.base_rounded_total
if (doc.base_rounding_adjustment and doc.base_rounded_total)
else doc.base_grand_total,
doc.precision("base_grand_total"),
)
if grand_total and not doc.is_internal_transfer():
self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
def add_supplier_gl_entry(
self,
gl_entries,
base_grand_total,
grand_total,
against_account=None,
remarks=None,
skip_merge=False,
):
doc = self.doc
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
gl = {
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"due_date": doc.due_date,
"against": against_account or doc.against_expense_account,
"credit": base_grand_total,
"credit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"credit_in_transaction_currency": grand_total,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"project": doc.project,
"cost_center": doc.cost_center,
"_skip_merge": skip_merge,
}
if remarks:
gl["remarks"] = remarks
gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc))
def make_item_gl_entries(self, gl_entries):
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)
stock_items = doc.get_stock_items()
if doc.update_stock and doc.auto_accounting_for_stock:
inventory_account_map = doc.get_inventory_account_map()
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
voucher_wise_stock_value = {}
if doc.update_stock:
stock_ledger_entries = frappe.get_all(
"Stock Ledger Entry",
fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0},
)
for d in stock_ledger_entries:
voucher_wise_stock_value.setdefault(
(d.voucher_detail_no, d.warehouse), d.stock_value_difference
)
valuation_tax_accounts = [
d.account_head
for d in doc.get("taxes")
if d.category in ("Valuation", "Valuation and Total")
and flt(d.base_tax_amount_after_discount_amount)
]
exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
provisional_accounting_for_non_stock_items = cint(
frappe.get_cached_value(
"Company", doc.company, "enable_provisional_accounting_for_non_stock_items"
)
)
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
for item in doc.get("items"):
if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate):
if item.item_code:
frappe.get_cached_value("Item", item.item_code, "asset_category")
if (
doc.update_stock
and doc.auto_accounting_for_stock
and (item.item_code in stock_items or item.is_fixed_asset)
):
account_currency = get_account_currency(item.expense_account)
warehouse_debit_amount = self.make_stock_adjustment_entry(
gl_entries, item, voucher_wise_stock_value, account_currency
)
if item.from_warehouse:
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
_inv_dict_from_warehouse = doc.get_inventory_account_dict(
item, inventory_account_map, "from_warehouse"
)
gl_entries.append(
self.get_gl_dict(
{
"account": _inv_dict["account"],
"against": _inv_dict_from_warehouse["account"],
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": warehouse_debit_amount,
"debit_in_transaction_currency": item.net_amount,
},
_inv_dict["account_currency"],
item=item,
)
)
credit_amount = item.base_net_amount
if doc.is_internal_supplier and item.valuation_rate:
credit_amount = flt(item.valuation_rate * item.stock_qty)
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
gl_entries.append(
self.get_gl_dict(
{
"account": _inv_dict_from_warehouse["account"],
"against": _inv_dict["account"],
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
"debit_in_transaction_currency": item.net_amount,
},
_inv_dict_from_warehouse["account_currency"],
item=item,
)
)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": doc.supplier,
"debit": flt(item.base_net_amount, item.precision("base_net_amount")),
"debit_in_transaction_currency": item.net_amount,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
else:
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": doc.supplier,
"debit": warehouse_debit_amount,
"debit_in_transaction_currency": flt(
warehouse_debit_amount / doc.conversion_rate,
item.precision("net_amount"),
),
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# Amount added through landed-cost-voucher
if landed_cost_entries:
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):
supplier_wh_dict = doc.get_inventory_account_dict(
item, inventory_account_map, "supplier_warehouse"
)
supplier_inventory_account = supplier_wh_dict["account"]
if not supplier_inventory_account:
frappe.throw(
_("Please set account in Warehouse {0}").format(doc.supplier_warehouse)
)
gl_entries.append(
self.get_gl_dict(
{
"account": supplier_inventory_account,
"against": item.expense_account,
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(item.rm_supp_cost),
"credit_in_transaction_currency": item.net_amount,
},
supplier_wh_dict["account_currency"],
item=item,
)
)
else:
expense_account = (
item.expense_account
if (not item.enable_deferred_expense or doc.is_return)
else item.deferred_expense_account
)
account_currency = get_account_currency(expense_account)
amount, base_amount = tax_service.get_amount_and_base_amount(item, None)
if provisional_accounting_for_non_stock_items:
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
# When Update Stock is disabled, this invoice has no stock impact: the linked
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
# amount against the supplier - booking PPV again would double count it and leave
# SRBNB partially uncleared.
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# check if the exchange rate has changed
if (
not adjust_incoming_rate
and item.get("purchase_receipt")
and doc.auto_accounting_for_stock
):
if (
exchange_rate_map[item.purchase_receipt]
and doc.conversion_rate != exchange_rate_map[item.purchase_receipt]
and item.net_rate == net_rate_map[item.pr_detail]
and item.item_code in stock_items
):
discrepancy_caused_by_exchange_rate_difference = (
item.qty * item.net_rate
) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate)
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": discrepancy_caused_by_exchange_rate_difference,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.get_company_default("exchange_gain_loss_account"),
"against": doc.supplier,
"credit": discrepancy_caused_by_exchange_rate_difference,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if (
doc.auto_accounting_for_stock
and doc.is_opening == "No"
and item.item_code in stock_items
and item.item_tax_amount
):
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
if item.purchase_receipt and valuation_tax_accounts:
negative_expense_booked_in_pr = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": item.purchase_receipt,
"account": ["in", valuation_tax_accounts],
},
pluck="name",
)
(
doc.get_company_default("asset_received_but_not_billed")
if item.is_fixed_asset
else doc.stock_received_but_not_billed
)
if not negative_expense_booked_in_pr:
gl_entries.append(
self.get_gl_dict(
{
"account": doc.stock_received_but_not_billed,
"against": doc.supplier,
"debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
"debit_in_transaction_currency": flt(
item.item_tax_amount / doc.conversion_rate,
item.precision("item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
"cost_center": doc.cost_center,
"project": item.project or doc.project,
},
item=item,
)
)
doc.negative_expense_to_be_booked += flt(
item.item_tax_amount, item.precision("item_tax_amount")
)
if item.is_fixed_asset and item.landed_cost_voucher_amount:
self.update_net_purchase_amount_for_linked_assets(item)
def get_provisional_accounts(self):
doc = self.doc
self.provisional_accounts = frappe._dict()
linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt}
if not linked_purchase_receipts:
return
pr_items = frappe.get_all(
"Purchase Receipt Item",
filters={"parent": ("in", linked_purchase_receipts)},
fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
)
default_provisional_account = doc.get_company_default("default_provisional_account")
provisional_accounts = {
d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
for d in pr_items
}
provisional_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": ("in", linked_purchase_receipts),
"account": ("in", provisional_accounts),
"is_cancelled": 0,
},
fields=["voucher_detail_no"],
)
rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
for item in pr_items:
self.provisional_accounts[item.name] = {
"provisional_account": item.provisional_expense_account or default_provisional_account,
"qty": item.qty,
"base_rate": item.base_rate,
"rate": item.rate,
"has_provisional_entry": item.name in rows_with_provisional_entries,
}
def make_provisional_gl_entry(self, gl_entries, item):
if item.purchase_receipt:
pr_item = self.provisional_accounts.get(item.pr_detail, {})
if pr_item.get("has_provisional_entry"):
purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
# Intentionally passing purchase invoice item to handle partial billing
purchase_receipt_doc.add_provisional_gl_entry(
item,
gl_entries,
self.doc.posting_date,
pr_item.get("provisional_account"),
reverse=1,
item_amount=(
(min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
* purchase_receipt_doc.get("conversion_rate")
),
)
def update_net_purchase_amount_for_linked_assets(self, item):
doc = self.doc
assets = frappe.db.get_all(
"Asset",
filters={
"purchase_invoice": doc.name,
"item_code": item.item_code,
"purchase_invoice_item": ("in", [item.name, ""]),
},
fields=["name", "asset_quantity"],
)
for asset in assets:
purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
frappe.db.set_value(
"Asset",
asset.name,
{
"net_purchase_amount": purchase_amount,
"purchase_amount": purchase_amount,
},
)
def get_stock_variance_account(self, item):
"""Return the account for stock valuation difference.
Standard Cost items use the Purchase Price Variance account. Other items use
the default expense account, falling back to the item expense account for
returns and the stock/asset received but not billed account for non-returns."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
from erpnext.stock.utils import get_valuation_method
if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
return get_purchase_price_variance_account(item.item_code, self.doc.company)
# 1. Primary choice: Company Default Expense / COGS Account
default_expense = self.doc.get_company_default("default_expense_account", ignore_validation=True)
if default_expense:
return default_expense
# 2. If default_expense_account is NOT set (Unconfigured):
# For returns, fall back to item.expense_account
if self.doc.is_return and item.expense_account:
return item.expense_account
# For non-returns, fall back to the clearing account used by Purchase Receipts.
stock_asset_rbnb = (
self.doc.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.doc.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
return stock_asset_rbnb or item.expense_account
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")
val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
warehouse_debit_amount = flt(
flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
net_amt_precision,
)
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
net_rate = item.base_net_amount
if item.sales_incoming_rate:
net_rate = item.qty * item.sales_incoming_rate
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
warehouse_debit_amount = flt(
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
self.get_gl_dict(
{
"account": cost_of_goods_sold_account,
"against": item.expense_account,
"debit": stock_adjustment_amt,
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
"remarks": doc.get("remarks") or _("Stock Adjustment"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
elif (
doc.update_stock
and voucher_wise_stock_value.get((item.name, item.warehouse))
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount
gl_entries.append(
self.get_gl_dict(
{
"account": cost_of_goods_sold_account,
"against": item.expense_account,
"debit": stock_adjustment_amt,
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
"remarks": doc.get("remarks") or _("Stock Adjustment"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
warehouse_debit_amount = stock_amount
return warehouse_debit_amount
def make_tax_gl_entries(self, gl_entries):
doc = self.doc
tax_service = TaxService(doc)
valuation_tax = {}
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
for tax in doc.get("taxes"):
amount, base_amount = tax_service.get_tax_amounts(tax, None)
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
account_currency = get_account_currency(tax.account_head)
dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"against": doc.supplier,
dr_or_cr: base_amount,
dr_or_cr + "_in_account_currency": base_amount
if account_currency == doc.company_currency
else amount,
dr_or_cr + "_in_transaction_currency": amount,
"cost_center": tax.cost_center,
},
account_currency,
item=tax,
)
)
if (
doc.is_opening == "No"
and tax.category in ("Valuation", "Valuation and Total")
and flt(base_amount)
and not doc.is_internal_transfer()
):
if doc.auto_accounting_for_stock and not tax.cost_center:
frappe.throw(
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
tax.idx, _(tax.category)
)
)
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
total_valuation_amount = sum(valuation_tax.values())
amount_including_divisional_loss = doc.negative_expense_to_be_booked
i = 1
for tax in doc.get("taxes"):
if valuation_tax.get(tax.name):
if i == len(valuation_tax):
applicable_amount = amount_including_divisional_loss
else:
applicable_amount = doc.negative_expense_to_be_booked * (
valuation_tax[tax.name] / total_valuation_amount
)
amount_including_divisional_loss -= applicable_amount
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"cost_center": tax.cost_center,
"against": doc.supplier,
"credit": applicable_amount,
"credit_in_transaction_currency": flt(
applicable_amount / doc.conversion_rate,
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
},
item=tax,
)
)
i += 1
if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax:
for tax in doc.get("taxes"):
if valuation_tax.get(tax.name):
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"cost_center": tax.cost_center,
"against": doc.supplier,
"credit": valuation_tax[tax.name],
"credit_in_transaction_currency": flt(
valuation_tax[tax.name] / doc.conversion_rate,
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
},
item=tax,
)
)
def make_internal_transfer_gl_entries(self, gl_entries):
doc = self.doc
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.unrealized_profit_loss_account,
"against": doc.supplier,
"credit": flt(doc.total_taxes_and_charges),
"credit_in_transaction_currency": flt(doc.total_taxes_and_charges),
"credit_in_account_currency": flt(doc.base_total_taxes_and_charges),
"cost_center": doc.cost_center,
},
account_currency,
item=doc,
)
)
def make_gl_entries_for_tax_withholding(self, gl_entries):
"""Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount."""
doc = self.doc
if not doc.apply_tds:
return
for row in doc.get("taxes"):
if not row.is_tax_withholding_account or not row.tax_amount:
continue
base_tds_amount = row.base_tax_amount_after_discount_amount
tds_amount = row.tax_amount_after_discount_amount
self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
self.add_supplier_gl_entry(
gl_entries,
-base_tds_amount,
-tds_amount,
against_account=row.account_head,
remarks=_("TDS Deducted"),
skip_merge=True,
)
def make_payment_gl_entries(self, gl_entries):
doc = self.doc
if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount:
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
bank_account_currency = get_account_currency(doc.cash_bank_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"against": doc.cash_bank_account,
"debit": doc.base_paid_amount,
"debit_in_account_currency": doc.base_paid_amount
if doc.party_account_currency == doc.company_currency
else doc.paid_amount,
"debit_in_transaction_currency": doc.paid_amount,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.cash_bank_account,
"against": doc.supplier,
"credit": doc.base_paid_amount,
"credit_in_account_currency": doc.base_paid_amount
if bank_account_currency == doc.company_currency
else doc.paid_amount,
"credit_in_transaction_currency": doc.paid_amount,
"cost_center": doc.cost_center,
},
bank_account_currency,
item=doc,
)
)
def make_write_off_gl_entry(self, gl_entries):
doc = self.doc
if doc.write_off_account and flt(doc.write_off_amount):
write_off_account_currency = get_account_currency(doc.write_off_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"against": doc.write_off_account,
"debit": doc.base_write_off_amount,
"debit_in_account_currency": doc.base_write_off_amount
if doc.party_account_currency == doc.company_currency
else doc.write_off_amount,
"debit_in_transaction_currency": doc.write_off_amount,
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
else doc.name,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.write_off_account,
"against": doc.supplier,
"credit": flt(doc.base_write_off_amount),
"credit_in_account_currency": doc.base_write_off_amount
if write_off_account_currency == doc.company_currency
else doc.write_off_amount,
"credit_in_transaction_currency": doc.write_off_amount,
"cost_center": doc.cost_center or doc.write_off_cost_center,
},
item=doc,
)
)
def make_gle_for_rounding_adjustment(self, gl_entries):
doc = self.doc
if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment:
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
)
if doc.is_opening == "Yes" and doc.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(doc.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", doc.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{
"account": round_off_account,
"against": doc.supplier,
"debit_in_account_currency": doc.rounding_adjustment,
"debit": doc.base_rounding_adjustment,
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else (doc.cost_center or round_off_cost_center),
},
item=doc,
)
)

View File

@@ -0,0 +1,285 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Base class and free functions for per-document GL entry composition.
``BaseGLComposer`` holds the document being composed and exposes
``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic
lives in the module-level free functions below (``doc`` as first argument), so
``AccountsController`` and ``StockController`` can delegate to them via thin
shims without forcing every GL-building doctype to inherit from those classes.
Subclasses implement ``compose`` to return the voucher-specific list of GL
entries.
"""
import frappe
from frappe import _
from frappe.utils import flt, formatdate
import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
from erpnext.accounts.services.taxes import set_balance_in_account_currency
from erpnext.accounts.utils import get_account_currency, get_fiscal_years
from erpnext.utilities.regional import temporary_flag
def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict:
"""Build a GL entry dict populated with doc-level fields."""
posting_date = args.get("posting_date") or doc.get("posting_date")
fiscal_years = get_fiscal_years(posting_date, company=doc.company)
if len(fiscal_years) > 1:
frappe.throw(
_("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format(
formatdate(posting_date)
)
)
else:
fiscal_year = fiscal_years[0][0]
gl_dict = frappe._dict(
{
"company": doc.company,
"posting_date": posting_date,
"fiscal_year": fiscal_year,
"voucher_type": doc.doctype,
"voucher_no": doc.name,
"remarks": doc.get("remarks") or doc.get("remark"),
"debit": 0,
"credit": 0,
"debit_in_account_currency": 0,
"credit_in_account_currency": 0,
"is_opening": doc.get("is_opening") or "No",
"party_type": None,
"party": None,
"project": doc.get("project"),
"post_net_value": args.get("post_net_value"),
"voucher_detail_no": args.get("voucher_detail_no"),
"voucher_subtype": get_voucher_subtype(doc),
}
)
with temporary_flag("company", doc.company):
update_gl_dict_with_regional_fields(doc, gl_dict)
update_gl_dict_with_app_based_fields(doc, gl_dict)
accounting_dimensions = get_accounting_dimensions()
dimension_dict = frappe._dict()
for dimension in accounting_dimensions:
value = doc.get(dimension)
if item and item.get(dimension):
value = item.get(dimension)
if isinstance(value, list | dict):
continue
dimension_dict[dimension] = value
gl_dict.update(dimension_dict)
gl_dict.update(args)
if not account_currency:
account_currency = get_account_currency(gl_dict.account)
if gl_dict.account and doc.doctype not in [
"Journal Entry",
"Period Closing Voucher",
"Payment Entry",
"Purchase Receipt",
"Purchase Invoice",
"Stock Entry",
]:
validate_account_currency(doc, gl_dict.account, account_currency)
if gl_dict.account and doc.doctype not in [
"Journal Entry",
"Period Closing Voucher",
"Payment Entry",
]:
set_balance_in_account_currency(
gl_dict,
account_currency,
args.get("transaction_exchange_rate") or doc.get("conversion_rate"),
doc.company_currency,
)
if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]:
gl_dict.update(
{
"transaction_currency": doc.get("currency") or doc.company_currency,
"transaction_exchange_rate": args.get("transaction_exchange_rate")
or doc.get("conversion_rate", 1),
"debit_in_transaction_currency": get_value_in_transaction_currency(
doc, account_currency, gl_dict, "debit"
),
"credit_in_transaction_currency": get_value_in_transaction_currency(
doc, account_currency, gl_dict, "credit"
),
}
)
if not args.get("against_voucher_type") and doc.get("against_voucher_type"):
gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")})
if not args.get("against_voucher") and doc.get("against_voucher"):
gl_dict.update({"against_voucher": doc.get("against_voucher")})
return gl_dict
def add_gl_entry(
doc,
gl_entries: list,
account: str,
cost_center: str,
debit: float,
credit: float,
remarks: str,
against_account: str,
debit_in_account_currency: float | None = None,
credit_in_account_currency: float | None = None,
account_currency: str | None = None,
project: str | None = None,
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.
`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,
"debit": debit,
"credit": credit,
"against": against_account,
"remarks": remarks,
}
if project:
gl_entry["project"] = project
if voucher_detail_no:
gl_entry["voucher_detail_no"] = voucher_detail_no
if debit_in_account_currency:
gl_entry["debit_in_account_currency"] = debit_in_account_currency
if credit_in_account_currency:
gl_entry["credit_in_account_currency"] = credit_in_account_currency
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))
def get_voucher_subtype(doc) -> str:
voucher_subtypes = {
"Journal Entry": "voucher_type",
"Payment Entry": "payment_type",
"Stock Entry": "stock_entry_type",
"Asset Capitalization": "entry_type",
}
for method_name in frappe.get_hooks("voucher_subtypes"):
voucher_subtype = frappe.get_attr(method_name)(doc)
if voucher_subtype:
return voucher_subtype
if doc.doctype in voucher_subtypes:
return doc.get(voucher_subtypes[doc.doctype])
elif doc.doctype == "Purchase Receipt" and doc.is_return:
return "Purchase Return"
elif doc.doctype == "Delivery Note" and doc.is_return:
return "Sales Return"
elif doc.doctype == "Sales Invoice" and doc.is_return:
return "Credit Note"
elif doc.doctype == "Sales Invoice" and doc.is_debit_note:
return "Debit Note"
elif doc.doctype == "Purchase Invoice" and doc.is_return:
return "Debit Note"
return doc.doctype
def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float:
if account_currency == doc.get("currency"):
return gl_dict.get(field + "_in_account_currency")
return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1))
def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None:
valid_currency = [doc.company_currency]
if doc.get("currency") and doc.currency != doc.company_currency:
valid_currency.append(doc.currency)
if account_currency not in valid_currency:
frappe.throw(
_("Account {0} is invalid. Account Currency must be {1}").format(
account, (" " + _("or") + " ").join(valid_currency)
)
)
@erpnext.allow_regional
def update_gl_dict_with_regional_fields(doc, gl_dict):
pass
def update_gl_dict_with_app_based_fields(doc, gl_dict):
for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]):
frappe.get_attr(method)(doc, gl_dict)
class BaseGLComposer:
def __init__(self, doc):
self.doc = doc
def compose(self):
raise NotImplementedError
def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict:
return get_gl_dict(self.doc, args, account_currency, item)
def add_gl_entry(
self,
gl_entries: list,
account: str,
cost_center: str,
debit: float,
credit: float,
remarks: str,
against_account: str,
debit_in_account_currency: float | None = None,
credit_in_account_currency: float | None = None,
account_currency: str | None = None,
project: str | None = None,
voucher_detail_no: str | None = None,
item=None,
posting_date=None,
dimensions: dict | None = None,
) -> None:
add_gl_entry(
self.doc,
gl_entries,
account,
cost_center,
debit,
credit,
remarks,
against_account,
debit_in_account_currency,
credit_in_account_currency,
account_currency,
project,
voucher_detail_no,
item,
posting_date,
dimensions,
)

View File

@@ -1978,6 +1978,7 @@ class StockController(AccountsController):
voucher_detail_no=None,
item=None,
posting_date=None,
dimensions=None,
):
gl_entry = {
"account": account,
@@ -1988,6 +1989,7 @@ class StockController(AccountsController):
"remarks": remarks,
}
<<<<<<< HEAD
if project:
gl_entry.update({"project": project})
@@ -2004,6 +2006,26 @@ class StockController(AccountsController):
gl_entry.update({"posting_date": posting_date})
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
=======
add_gl_entry(
self,
gl_entries,
account,
cost_center,
debit,
credit,
remarks,
against_account,
debit_in_account_currency,
credit_in_account_currency,
account_currency,
project,
voucher_detail_no,
item,
posting_date,
dimensions,
)
>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char (#56981))
def update_stock_reservation_entries(self):
def get_sre_list():

View File

@@ -559,6 +559,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

@@ -489,7 +489,11 @@ erpnext.patches.v16_0.remove_mandatory_from_inv_dimension_fields
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
<<<<<<< HEAD
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
=======
erpnext.patches.v16_0.create_accounting_dimensions_in_landed_cost_taxes_and_charges
>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981))
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
erpnext.patches.v16_0.rename_ar_ap_ageing_filter

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

@@ -88,6 +88,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
@@ -196,6 +198,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"))
@@ -519,3 +619,155 @@ def get_vendor_invoice_query(filters):
query = query.where(doctype.name == filters.get("name"))
return query
<<<<<<< HEAD
=======
def set_landed_cost_voucher_amount(doc):
"""Set landed_cost_voucher_amount on the receipt document's items from submitted LCVs."""
for d in doc.get("items"):
lcv_item = frappe.qb.DocType("Landed Cost Item")
query = (
frappe.qb.from_(lcv_item)
.select(Sum(lcv_item.applicable_charges), Max(lcv_item.cost_center))
.where((lcv_item.docstatus == 1) & (lcv_item.receipt_document == doc.name))
)
if doc.doctype == "Stock Entry":
query = query.where(lcv_item.stock_entry_item == d.name)
else:
query = query.where(lcv_item.purchase_receipt_item == d.name)
lc_voucher_data = query.run(as_list=True)
d.landed_cost_voucher_amount = lc_voucher_data[0][0] if lc_voucher_data else 0.0
if not d.cost_center and lc_voucher_data and lc_voucher_data[0][1]:
d.db_set("cost_center", lc_voucher_data[0][1])
def has_landed_cost_amount(doc):
for row in doc.items:
if row.get("landed_cost_voucher_amount"):
return True
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):
"""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
landed_cost_vouchers = frappe.get_all(
"Landed Cost Purchase Receipt",
fields=["parent"],
filters={"receipt_document": doc.name, "docstatus": 1},
)
if not landed_cost_vouchers:
return
item_account_wise_cost = {}
dimension_fields = get_lcv_dimension_fields()
row_fieldname = "purchase_receipt_item"
if doc.doctype == "Stock Entry":
row_fieldname = "stock_entry_item"
for lcv in landed_cost_vouchers:
landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent)
based_on_field = "applicable_charges"
# Use amount field for total item cost for manually cost distributed LCVs
if landed_cost_voucher_doc.distribute_charges_based_on != "Distribute Manually":
based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on)
total_item_cost = 0
if based_on_field:
for item in landed_cost_voucher_doc.items:
total_item_cost += item.get(based_on_field)
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
dimensions = get_row_dimensions(account, item, dimension_fields)
group_key = (
account.expense_account,
tuple(dimensions.get(field) for field in dimension_fields),
)
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.base_amount += (
account.base_amount * item.get(based_on_field) / total_item_cost
)
else:
# 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 {key: list(charges.values()) for key, charges in item_account_wise_cost.items()}
>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char (#56981))

View File

@@ -1408,3 +1408,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

@@ -0,0 +1,457 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import cint, flt
import erpnext
from erpnext.accounts.general_ledger import process_gl_map
from erpnext.accounts.utils import get_account_currency
from erpnext.stock import get_warehouse_account
from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
class PurchaseReceiptGLComposer(BaseStockGLComposer):
"""GL composer for Purchase Receipt.
Builds GL entries for stock/asset inward, taxes, purchase expense, and
regional adjustments. Does not delegate to the base stock GL loop —
PR has its own per-item logic (provisional accounting, fixed assets, LCV,
sub-contracting, divisional loss).
"""
def compose(
self,
inventory_account_map: dict | None = None,
via_landed_cost_voucher: bool = False,
) -> list:
gl_entries = []
self._make_item_gl_entries(gl_entries, inventory_account_map)
self._make_tax_gl_entries(gl_entries, via_landed_cost_voucher)
self.doc.set_gl_entry_for_purchase_expense(gl_entries)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_regional_gl_entries
update_regional_gl_entries(gl_entries, self.doc)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None:
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
provisional_accounting_for_non_stock_items = cint(
frappe.db.get_value("Company", doc.company, "enable_provisional_accounting_for_non_stock_items")
)
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))
def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name):
account_currency = get_account_currency(stock_asset_account_name)
if not stock_asset_account_name:
validate_account("Asset or warehouse account")
self.add_gl_entry(
gl_entries=gl_entries,
account=stock_asset_account_name,
cost_center=d.cost_center,
debit=stock_value_diff,
credit=0.0,
remarks=remarks,
against_account=stock_asset_rbnb,
account_currency=account_currency,
project=item.project,
item=item,
)
def make_stock_received_but_not_billed_entry(item):
if (
doc.get("is_return")
and item.return_qty_from_rejected_warehouse
and not frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
)
):
return 0.0
account = stock_asset_rbnb
if item.from_warehouse:
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "from_warehouse")
account = _inv_dict["account"]
account_currency = get_account_currency(account)
credit_amount = (
flt(item.base_net_amount, item.precision("base_net_amount"))
if account_currency == doc.company_currency
else flt(item.net_amount, item.precision("net_amount"))
)
outgoing_amount = item.base_net_amount
if doc.is_internal_transfer() and item.valuation_rate:
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
credit_amount = outgoing_amount
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
credit_amount = outgoing_amount
if credit_amount:
if not account:
validate_account("Stock or Asset Received But Not Billed")
self.add_gl_entry(
gl_entries=gl_entries,
account=account,
cost_center=item.cost_center,
debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
credit=0.0,
remarks=remarks,
against_account=stock_asset_account_name,
debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
account_currency=account_currency,
project=item.project,
item=item,
)
if d.get("purchase_invoice"):
if (
exchange_rate_map[item.purchase_invoice]
and doc.conversion_rate != exchange_rate_map[item.purchase_invoice]
and item.net_rate == net_rate_map[item.purchase_invoice_item]
):
discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * (
exchange_rate_map[item.purchase_invoice] - doc.conversion_rate
)
self.add_gl_entry(
gl_entries=gl_entries,
account=account,
cost_center=item.cost_center,
debit=0.0,
credit=discrepancy_caused_by_exchange_rate_difference,
remarks=remarks,
against_account=doc.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
project=item.project,
item=item,
)
self.add_gl_entry(
gl_entries=gl_entries,
account=doc.get_company_default("exchange_gain_loss_account"),
cost_center=d.cost_center,
debit=discrepancy_caused_by_exchange_rate_difference,
credit=0.0,
remarks=remarks,
against_account=doc.supplier,
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
account_currency=account_currency,
project=item.project,
item=item,
)
return outgoing_amount
def make_landed_cost_gl_entries(item):
if not (item.landed_cost_voucher_amount and landed_cost_entries):
return
for entry in landed_cost_entries.get((item.item_code, item.name), []):
if not (entry.amount or entry.base_amount):
continue
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():
return
amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount"))
if amount and not item.is_fixed_asset:
self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item)
def make_amount_difference_entry(item):
if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
account_currency = get_account_currency(stock_asset_rbnb)
self.add_gl_entry(
gl_entries=gl_entries,
account=stock_asset_rbnb,
cost_center=item.cost_center,
debit=0.0,
credit=flt(item.amount_difference_with_purchase_invoice),
remarks=_("Adjustment based on Purchase Invoice rate"),
against_account=stock_asset_account_name,
account_currency=account_currency,
project=item.project,
item=item,
)
def make_sub_contracting_gl_entries(item):
if flt(item.rm_supp_cost) and supplier_warehouse_account:
self.add_gl_entry(
gl_entries=gl_entries,
account=supplier_warehouse_account,
cost_center=item.cost_center,
debit=0.0,
credit=flt(item.rm_supp_cost),
remarks=remarks,
against_account=stock_asset_account_name,
account_currency=supplier_warehouse_account_currency,
project=item.project,
item=item,
)
def make_divisional_loss_gl_entry(item, outgoing_amount):
if item.is_fixed_asset:
return
valuation_amount_as_per_doc = (
flt(outgoing_amount, d.precision("base_net_amount"))
+ flt(item.landed_cost_voucher_amount)
+ flt(item.rm_supp_cost)
+ flt(item.item_tax_amount)
+ flt(item.amount_difference_with_purchase_invoice)
)
divisional_loss = flt(
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
)
if item.get("rejected_qty") and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
divisional_loss -= rejected_item_cost
if divisional_loss:
loss_account = self.get_divisional_loss_account(item, stock_asset_rbnb)
cost_center = item.cost_center or frappe.get_cached_value(
"Company", doc.company, "cost_center"
)
account_currency = get_account_currency(loss_account)
self.add_gl_entry(
gl_entries=gl_entries,
account=loss_account,
cost_center=cost_center,
debit=divisional_loss,
credit=0.0,
remarks=remarks,
against_account=stock_asset_account_name,
account_currency=account_currency,
project=item.project,
item=item,
)
for d in doc.get("items"):
remarks = doc.get("remarks") or _("Accounting Entry for {0}").format(
"Asset" if d.is_fixed_asset else "Stock"
)
if (
provisional_accounting_for_non_stock_items
and d.item_code not in stock_items
and flt(d.qty)
and d.get("provisional_expense_account")
and not d.is_fixed_asset
):
doc.add_provisional_gl_entry(
d, gl_entries, doc.posting_date, d.get("provisional_expense_account")
)
elif flt(d.qty) and (flt(d.valuation_rate) or doc.is_return):
if not (
(erpnext.is_perpetual_inventory_enabled(doc.company) and d.item_code in stock_items)
or (d.is_fixed_asset and not d.purchase_invoice)
):
continue
stock_asset_rbnb = (
doc.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
else doc.get_company_default("stock_received_but_not_billed")
)
if d.is_fixed_asset:
stock_asset_account_name = d.expense_account
stock_value_diff = (
flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount)
)
elif inventory_account := doc.get_inventory_account_dict(d, inventory_account_map):
stock_value_diff = get_stock_value_difference(doc.name, d.name, d.warehouse)
stock_asset_account_name = inventory_account["account"]
supplier_warehouse_account = None
supplier_warehouse_account_currency = None
if doc.supplier_warehouse:
supplier_warehouse_account = get_warehouse_account(
frappe.get_cached_doc("Warehouse", doc.supplier_warehouse),
raise_error=bool(flt(d.rm_supp_cost)),
)
if supplier_warehouse_account:
supplier_warehouse_account_currency = get_account_currency(
supplier_warehouse_account
)
if (
flt(stock_value_diff) == flt(d.rm_supp_cost)
and supplier_warehouse_account
and stock_asset_account_name == supplier_warehouse_account
):
continue
if (flt(d.valuation_rate) or doc.is_return or d.is_fixed_asset) and flt(d.qty):
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
outgoing_amount = make_stock_received_but_not_billed_entry(d)
make_landed_cost_gl_entries(d)
make_expenses_added_to_stock_entries(d)
make_amount_difference_entry(d)
make_sub_contracting_gl_entries(d)
make_divisional_loss_gl_entry(d, outgoing_amount)
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
and d.rejected_warehouse
and d.rejected_warehouse not in warehouse_with_no_account
):
warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse)
if d.is_fixed_asset and d.landed_cost_voucher_amount:
doc.update_assets(d, d.valuation_rate)
if d.rejected_qty and frappe.db.get_single_value(
"Buying Settings", "set_valuation_rate_for_rejected_materials"
):
stock_asset_rbnb = (
doc.get_company_default("asset_received_but_not_billed")
if d.is_fixed_asset
else doc.get_company_default("stock_received_but_not_billed")
)
stock_value_diff = get_stock_value_difference(doc.name, d.name, d.rejected_warehouse)
_inv_dict = doc.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse")
stock_asset_account_name = _inv_dict["account"]
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
if not d.qty:
make_stock_received_but_not_billed_entry(d)
if warehouse_with_no_account:
frappe.msgprint(
_("No accounting entries for the following warehouses")
+ ": \n"
+ "\n".join(warehouse_with_no_account)
)
def get_divisional_loss_account(self, item, stock_asset_rbnb):
"""Account that absorbs the difference between the document value and the value actually
booked into stock. For a Standard Cost item this difference is a purchase price variance
(receipt rate vs standard rate), so it goes to the Purchase Price Variance account; for all
other items it keeps the existing behaviour (default expense account, or the item's expense
account on a return)."""
from erpnext.stock.utils import get_valuation_method
doc = self.doc
if item.item_code and get_valuation_method(item.item_code, doc.company) == "Standard Cost":
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
return get_purchase_price_variance_account(item.item_code, doc.company)
loss_account = (
doc.get_company_default("default_expense_account", ignore_validation=True) or stock_asset_rbnb
)
if doc.is_return and item.expense_account:
loss_account = item.expense_account
return loss_account
def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None:
doc = self.doc
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")])
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
# tax row name. This is what must be credited to each tax account - a non-stock item's share
# of a spread-across-all-items charge is not capitalized, so it is excluded here.
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
valuation_tax = {}
for tax in doc.get("taxes"):
if tax.category in ("Valuation", "Valuation and Total") and flt(
tax.base_tax_amount_after_discount_amount
):
if not tax.cost_center:
frappe.throw(
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
tax.idx, _(tax.category)
)
)
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
if negative_expense_to_be_booked and valuation_tax:
against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0])
total_valuation_amount = sum(valuation_tax.values())
amount_including_divisional_loss = negative_expense_to_be_booked
i = 1
for tax in doc.get("taxes"):
if valuation_tax.get(tax.name):
account = tax.account_head
if i == len(valuation_tax):
applicable_amount = amount_including_divisional_loss
else:
applicable_amount = negative_expense_to_be_booked * (
valuation_tax[tax.name] / total_valuation_amount
)
amount_including_divisional_loss -= applicable_amount
self.add_gl_entry(
gl_entries=gl_entries,
account=account,
cost_center=tax.cost_center,
debit=0.0,
credit=applicable_amount,
remarks=doc.remarks or _("Accounting Entry for Stock"),
against_account=against_accounts,
item=tax,
)
i += 1

View File

@@ -0,0 +1,336 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.general_ledger import process_gl_map
from erpnext.accounts.utils import get_account_currency
from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
class StockEntryGLComposer(BaseStockGLComposer):
"""GL composer for Stock Entry.
Extends the base stock GL loop with additional-cost entries (from the
``additional_costs`` child table) and landed-cost voucher adjustments.
The difference is posted to warehouse/balance-sheet accounts, so P&L
enforcement on the expense account is off.
"""
enforce_pl_expense_account = False
book_expenses_added_to_stock = True
def compose(self, inventory_account_map: dict | None = None) -> list:
doc = self.doc
gl_entries = super().compose(inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
else:
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
divide_based_on = total_basic_amount
if doc.get("additional_costs") and not total_basic_amount:
divide_based_on = sum(item.qty for item in doc.get("items"))
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
total_basic_amount, divide_based_on
)
if item_account_wise_additional_cost:
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
self._append_lcv_gl_entries(gl_entries, inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
self._append_manufacturing_variance_gl_entries(gl_entries, inventory_account_map)
elif doc.purpose == "Material Receipt":
self._append_receipt_variance_gl_entries(gl_entries)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def _append_manufacturing_variance_gl_entries(
self, gl_entries: list, inventory_account_map: dict
) -> None:
"""For Standard Cost finished goods produced via Manufacture/Repack, stock is booked at the item's
standard rate, while the entry consumes raw-material (plus additional/landed) cost. The difference
is a manufacturing variance and is reclassified from the finished good's expense account to the
Manufacturing Variance account (mirrors Purchase Price Variance on a Purchase Receipt)."""
precision = self.get_debit_field_precision()
# Reuse the SLE map the base composer already fetched in compose() to avoid a second identical query.
sle_map = self._sle_map
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_manufacturing_variance_account,
)
for d in self.doc.get("items"):
variance = self._get_finished_good_variance(d, sle_map, precision)
if variance:
account = get_manufacturing_variance_account(d.item_code, self.doc.company)
remarks = self.doc.get("remarks") or _("Manufacturing Variance for {0}").format(d.item_code)
self._append_standard_cost_variance_pair(
gl_entries, d, variance, account, remarks, inventory_account_map
)
def _append_receipt_variance_gl_entries(self, gl_entries: list) -> None:
"""For a Standard Cost item received via Material Receipt, stock is booked at the item's standard
rate while the row may carry a manually-set basic rate plus additional/landed cost. The gap
between that intended cost and the standard value is a purchase price variance, reclassified from
the item's expense account to the Purchase Price Variance account."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
precision = self.get_debit_field_precision()
sle_map = self._sle_map
for d in self.doc.get("items"):
variance = self._get_receipt_variance(d, sle_map, precision)
if variance:
account = get_purchase_price_variance_account(d.item_code, self.doc.company)
remarks = self.doc.get("remarks") or _("Purchase Price Variance for {0}").format(d.item_code)
self._append_standard_cost_variance_pair(gl_entries, d, variance, account, remarks)
def _get_receipt_variance(self, item, sle_map, precision) -> float:
"""Purchase price variance for a Standard Cost item on a Material Receipt: the gap between the full
computed incoming cost (basic amount + additional cost + LCV, i.e. ``amount``) and the standard
value booked into stock. 0 for anything that is not a plain Standard Cost receipt row."""
from erpnext.stock.utils import get_valuation_method
if not item.t_warehouse or item.s_warehouse:
return 0.0
if (
item.get("is_finished_item")
or item.get("secondary_item_type")
or item.get("is_legacy_scrap_item")
):
return 0.0
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
return 0.0
standard_value = sum(
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
)
return flt(flt(item.amount) - standard_value, precision)
def _get_finished_good_variance(self, item, sle_map, precision) -> float:
"""Manufacturing variance for a Standard Cost finished good: the gap between the full computed
incoming cost (raw-material share + additional cost + LCV, i.e. ``amount``) and the standard value
actually booked into stock. Positive = consumed more than standard (unfavorable). 0 for anything
that is not a Standard Cost finished good."""
from erpnext.stock.utils import get_valuation_method
if not item.is_finished_item or not item.t_warehouse:
return 0.0
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
return 0.0
# Value actually booked into stock for this finished good = qty * standard rate.
standard_value = sum(
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
)
return flt(flt(item.amount) - standard_value, precision)
def _append_standard_cost_variance_pair(
self,
gl_entries: list,
item,
variance: float,
variance_account: str,
remarks: str,
inventory_account_map: dict | None = None,
) -> None:
"""Reclassify ``variance`` from the item's expense account to the given variance account,
restoring the expense account to the value it would carry without Standard Cost."""
doc = self.doc
cost_center = item.cost_center or frappe.get_cached_value("Company", doc.company, "cost_center")
project = item.project or doc.get("project")
inventory_account = None
if inventory_account_map:
inventory_account = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")[
"account"
]
gl_entries.append(
self.get_gl_dict(
{
"account": variance_account,
"against": item.expense_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": variance,
"project": project,
},
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": inventory_account or variance_account,
"cost_center": cost_center,
"remarks": remarks,
"debit": -1 * variance,
"project": project,
},
item=item,
)
)
def _build_additional_cost_per_item_account(
self, total_basic_amount: float, divide_based_on: float
) -> dict:
doc = self.doc
item_account_wise_additional_cost = {}
for t in doc.get("additional_costs"):
for d in doc.get("items"):
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
continue
elif not d.t_warehouse:
continue
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
)
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
return item_account_wise_additional_cost
def get_valuation_method(self, item_code: str) -> str:
from erpnext.stock.utils import get_valuation_method
return get_valuation_method(item_code, self.doc.company)
def _append_additional_cost_gl_entries(
self, gl_entries: list, item_account_wise_additional_cost: dict
) -> None:
doc = self.doc
precision = self.get_debit_field_precision()
for d in doc.get("items"):
for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items():
if not amount:
continue
amount["amount"] = flt(amount["amount"], precision)
amount["base_amount"] = flt(amount["base_amount"], precision)
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": d.expense_account,
"cost_center": d.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit_in_account_currency": flt(amount["amount"]),
"credit": flt(amount["base_amount"]),
},
item=d,
)
)
if self.get_valuation_method(d.item_code) == "Standard Cost":
gl_entries.append(
self.get_gl_dict(
{
"account": d.expense_account,
"against": account,
"cost_center": d.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": flt(amount["base_amount"]),
},
item=d,
)
)
else:
gl_entries.append(
self.get_gl_dict(
{
"account": d.expense_account,
"against": account,
"cost_center": d.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": -1 * flt(amount["base_amount"]),
},
item=d,
)
)
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:
return
for item in doc.get("items"):
if item.s_warehouse:
continue
for entry in landed_cost_entries.get((item.item_code, item.name), []):
if not (entry.amount or entry.base_amount):
continue
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)
)
_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

@@ -0,0 +1,276 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.general_ledger import process_gl_map
from erpnext.accounts.utils import get_account_currency
from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
class SubcontractingReceiptGLComposer(BaseStockGLComposer):
"""GL composer for Subcontracting Receipt.
Builds GL entries for accepted stock, service cost, supplier warehouse
(raw materials), additional costs, LCV, and divisional loss.
"""
def compose(self, inventory_account_map: dict | None = None) -> list:
import erpnext
doc = self.doc
if not erpnext.is_perpetual_inventory_enabled(doc.company):
return []
gl_entries = []
self._make_item_gl_entries(gl_entries, inventory_account_map)
self._make_item_gl_entries_for_lcv(gl_entries, inventory_account_map)
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None:
doc = self.doc
warehouse_with_no_account = []
supplied_items_details = frappe._dict()
for item in doc.supplied_items:
supplied_items_details.setdefault(item.reference_name, []).append(
frappe._dict(
{
"item_code": item.rm_item_code,
"amount": item.amount,
"expense_account": item.expense_account,
"cost_center": item.cost_center,
}
)
)
for item in doc.items:
if flt(item.rate) and flt(item.qty):
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
if _inv_dict.get("account"):
stock_value_diff = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Subcontracting Receipt",
"voucher_no": doc.name,
"voucher_detail_no": item.name,
"warehouse": item.warehouse,
"is_cancelled": 0,
},
"stock_value_difference",
)
remarks = doc.get("remarks") or _("Accounting Entry for Stock")
self.add_gl_entry(
gl_entries=gl_entries,
account=_inv_dict["account"],
cost_center=item.cost_center,
debit=stock_value_diff,
credit=0.0,
remarks=remarks,
against_account=item.expense_account,
account_currency=_inv_dict["account_currency"],
project=item.project,
item=item,
)
service_cost = flt(
item.service_cost_per_qty, item.precision("service_cost_per_qty")
) * flt(item.qty, item.precision("qty"))
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=item.cost_center,
debit=0.0,
credit=flt(stock_value_diff) - service_cost,
remarks=remarks,
against_account=_inv_dict["account"],
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
service_account = item.service_expense_account or item.expense_account
self.add_gl_entry(
gl_entries=gl_entries,
account=service_account,
cost_center=item.cost_center,
debit=0.0,
credit=service_cost,
remarks=remarks,
against_account=_inv_dict["account"],
account_currency=get_account_currency(service_account),
project=item.project,
item=item,
)
if flt(item.rm_supp_cost):
for rm_item in supplied_items_details.get(item.name):
_inv_dict = doc.get_inventory_account_dict(
rm_item, inventory_account_map, "supplier_warehouse"
)
self.add_gl_entry(
gl_entries=gl_entries,
account=_inv_dict.get("account"),
cost_center=rm_item.cost_center or item.cost_center,
debit=0.0,
credit=flt(rm_item.amount),
remarks=remarks,
against_account=rm_item.expense_account or item.expense_account,
account_currency=_inv_dict.get("account_currency"),
project=item.project,
item=item,
)
self.add_gl_entry(
gl_entries=gl_entries,
account=rm_item.expense_account or item.expense_account,
cost_center=rm_item.cost_center or item.cost_center,
debit=flt(rm_item.amount),
credit=0.0,
remarks=remarks,
against_account=_inv_dict.get("account"),
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
if item.additional_cost_per_qty:
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=doc.cost_center or doc.get_company_default("cost_center"),
debit=item.qty * item.additional_cost_per_qty,
credit=0.0,
remarks=remarks,
against_account=None,
account_currency=get_account_currency(item.expense_account),
)
if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")):
loss_account = doc.get_company_default(
"stock_adjustment_account", ignore_validation=True
)
self.add_gl_entry(
gl_entries=gl_entries,
account=loss_account,
cost_center=item.cost_center,
debit=0.0,
credit=divisional_loss,
remarks=remarks,
against_account=item.expense_account,
account_currency=get_account_currency(loss_account),
project=item.project,
item=item,
)
self.add_gl_entry(
gl_entries=gl_entries,
account=item.expense_account,
cost_center=item.cost_center,
debit=divisional_loss,
credit=0.0,
remarks=remarks,
against_account=loss_account,
account_currency=get_account_currency(item.expense_account),
project=item.project,
item=item,
)
elif (
item.warehouse not in warehouse_with_no_account
or item.rejected_warehouse not in warehouse_with_no_account
):
warehouse_with_no_account.append(item.warehouse)
for row in doc.additional_costs:
credit_amount = (
flt(row.base_amount)
if (row.base_amount or row.account_currency != doc.company_currency)
else flt(row.amount)
)
self.add_gl_entry(
gl_entries=gl_entries,
account=row.expense_account,
cost_center=doc.cost_center or doc.get_company_default("cost_center"),
debit=0.0,
credit=credit_amount,
remarks=remarks,
against_account=None,
account_currency=get_account_currency(row.expense_account),
)
if warehouse_with_no_account:
frappe.msgprint(
_("No accounting entries for the following warehouses")
+ ": \n"
+ "\n".join(warehouse_with_no_account)
)
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()
if not landed_cost_entries:
return
for item in doc.items:
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)
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
for entry in item_entries:
if not (entry.amount or entry.base_amount):
continue
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)
)
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),
)
# 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,
)