diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
new file mode 100644
index 00000000000..fa68cee9931
--- /dev/null
+++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
@@ -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}.
'{1}' account is required to post these values. Please set it in Company: {2}.
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,
+ )
+ )
diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py
new file mode 100644
index 00000000000..b2050125fe9
--- /dev/null
+++ b/erpnext/accounts/services/base_gl_composer.py
@@ -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,
+ )
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 32f35502a52..73d6d8f3bcb 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -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():
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index a4fc6766108..e3d03caf638 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -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",
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index e27a02f1e7e..9b062ece01c 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -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
diff --git a/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py
new file mode 100644
index 00000000000..4fa19fac744
--- /dev/null
+++ b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py
@@ -0,0 +1,11 @@
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_dimensions,
+ make_dimension_in_accounting_doctypes,
+)
+
+
+def execute():
+ dimensions_and_defaults = get_dimensions()
+ if dimensions_and_defaults:
+ for dimension in dimensions_and_defaults[0]:
+ make_dimension_in_accounting_doctypes(dimension, ["Landed Cost Taxes and Charges"])
diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json
index 6d638b6e59b..5e13fec040e 100644
--- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json
+++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json
@@ -17,7 +17,11 @@
"has_operating_cost",
"operation_id",
"qty",
- "operating_component"
+ "operating_component",
+ "accounting_dimensions_section",
+ "cost_center",
+ "dimension_col_break",
+ "project"
],
"fields": [
{
@@ -107,13 +111,34 @@
"label": "Operating Component",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "fieldname": "accounting_dimensions_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Dimensions"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
+ },
+ {
+ "fieldname": "dimension_col_break",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "project",
+ "fieldtype": "Link",
+ "label": "Project",
+ "options": "Project"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-05-19 12:21:07.953801",
+ "modified": "2026-08-04 10:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Landed Cost Taxes and Charges",
diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
index 4332b7429a6..1264de403ec 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
@@ -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))
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index a45787635ed..2f1c4e64ce3 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -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")
diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
new file mode 100644
index 00000000000..55dead0d69d
--- /dev/null
+++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
@@ -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
diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py
new file mode 100644
index 00000000000..6b20416b3b5
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py
@@ -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,
+ )
+ )
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
new file mode 100644
index 00000000000..e1217edb81e
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
@@ -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,
+ )