mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-27 13:55:19 +00:00
fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)
* fix(stock): carry accounting dimensions from Landed Cost Voucher charges into GL entries
* feat(stock): add accounting dimension fields to Landed Cost Taxes and Charges
The charge row had no dimension fields, so a dimension marked mandatory for
Profit and Loss accounts could not be supplied anywhere on the voucher.
Add the accounting dimensions section, cost center and project, and register
the doctype in accounting_dimension_doctypes so custom dimension fields are
created on it. The section and column break are required for that hook to
place the generated fields correctly.
Cost center deliberately omits the ":Company" default used by Purchase Taxes
and Charges: this child table is also the additional costs table on Stock
Entry and Subcontracting Receipt, and auto-filling it there would change
existing postings.
* refactor(stock): group landed cost charges by expense account and dimensions
get_item_account_wise_lcv_entries keyed its inner map by expense account
alone, so two charge rows posting to the same account - whether in one voucher
or across vouchers - were merged. Amounts accumulated correctly but any
per-row context was lost to whichever row was seen first.
Key the grouping by (expense account, dimension values) and return a list of
charges per receipt item, each carrying its own dimensions, so rows that
differ only by dimension stay distinct.
Dimensions resolve from the charge row first, then the voucher item row.
Blanks are left blank so the GL composers can fall back to the receipt item
and receipt document as before.
* refactor(accounts): allow explicit accounting dimensions on add_gl_entry
get_gl_dict derives dimensions from the parent document and the item row, and
reads only custom dimensions off the item - never cost center or project.
Callers that need to set a dimension from some other source had no way to do
so except by building the args dict by hand.
Add a dimensions argument that is merged into the entry before get_gl_dict is
called, and thread it through the StockController and BaseGLComposer wrappers.
* fix(stock): carry landed cost charge dimensions onto the GL entries
Landed cost charges are posted into the receipt document's ledger, and their
expense account is a Profit and Loss account. Until now the entry took its
dimensions from the receipt item, which cannot know about a voucher created
after it was submitted, so a dimension mandatory for P&L accounts failed.
Take cost center, project and custom dimensions from the charge row, falling
back to the receipt item and receipt document when the row leaves them blank.
Only the leg posting to the charge account is affected; the reclass leg keeps
the item's dimensions so it still nets against the base item entry.
Also skip charges that prorate to zero, and hoist the landed cost lookup in
the Purchase Receipt composer out of the item loop - it was reloading every
voucher once per item.
* fix(stock): report missing mandatory dimensions on the Landed Cost Voucher row
Submitting a voucher re-makes the receipt document's GL entries, so a missing
mandatory dimension surfaced as a GL Entry error naming an account, raised
from the middle of update_landed_cost, with nothing pointing at the row that
caused it.
Check the charge rows during validate instead, against both the mandatory
for P&L / Balance Sheet flags and the per-account Accounting Dimension Filter,
and name the row, the dimension and the account in the message.
The check resolves values through the same fallback chain the GL composers
use, so it does not reject a voucher that would have posted successfully.
* test(stock): cover accounting dimensions on landed cost vouchers
Covers the charge row reaching the GL entry, cost center and project
overriding the receipt item, the blank row still falling back to it, and two
charge rows - and two vouchers - on the same expense account with different
dimensions staying separate entries.
Also covers the mandatory P&L dimension being satisfied from the charge row,
the missing one being reported on the voucher, dimensions surviving a repost,
and each dimension netting to zero on cancellation.
* refactor(lcv): apply custom dimension overrides via .update()
---------
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
(cherry picked from commit 918e5a28db)
# Conflicts:
# erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
# erpnext/accounts/services/base_gl_composer.py
# erpnext/controllers/stock_controller.py
# erpnext/patches.txt
# erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py
# erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
# erpnext/stock/doctype/stock_entry/services/gl_composer.py
# erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
This commit is contained in:
committed by
Mergify
parent
7238ecb306
commit
41cee6e71b
@@ -0,0 +1,897 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
|
||||
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
|
||||
from erpnext.accounts.services.taxes import TaxService
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
"""Assembles the GL entries for a Purchase Invoice."""
|
||||
|
||||
def compose(self, inventory_account_map=None):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries
|
||||
from erpnext.accounts.general_ledger import merge_similar_entries
|
||||
|
||||
doc = self.doc
|
||||
doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
|
||||
|
||||
if doc.auto_accounting_for_stock:
|
||||
doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed")
|
||||
else:
|
||||
doc.stock_received_but_not_billed = None
|
||||
|
||||
doc.negative_expense_to_be_booked = 0.0
|
||||
gl_entries = []
|
||||
|
||||
self.make_supplier_gl_entry(gl_entries)
|
||||
self.make_item_gl_entries(gl_entries)
|
||||
self.make_precision_loss_gl_entry(gl_entries)
|
||||
|
||||
self.make_tax_gl_entries(gl_entries)
|
||||
self.make_internal_transfer_gl_entries(gl_entries)
|
||||
self.make_gl_entries_for_tax_withholding(gl_entries)
|
||||
|
||||
gl_entries = make_regional_gl_entries(gl_entries, doc)
|
||||
gl_entries = merge_similar_entries(gl_entries)
|
||||
|
||||
self.make_payment_gl_entries(gl_entries)
|
||||
self.make_write_off_gl_entry(gl_entries)
|
||||
self.make_gle_for_rounding_adjustment(gl_entries)
|
||||
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
|
||||
doc.set_gl_entry_for_purchase_expense(gl_entries)
|
||||
return gl_entries
|
||||
|
||||
def make_precision_loss_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
_round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
precision_loss = doc.get("base_net_total") - flt(
|
||||
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
|
||||
)
|
||||
|
||||
if precision_loss:
|
||||
gl_entries.append(
|
||||
doc.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.supplier,
|
||||
"credit": precision_loss,
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else doc.cost_center or round_off_cost_center,
|
||||
"remarks": _("Net total calculation precision loss"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def make_supplier_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
grand_total = (
|
||||
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
|
||||
)
|
||||
base_grand_total = flt(
|
||||
doc.base_rounded_total
|
||||
if (doc.base_rounding_adjustment and doc.base_rounded_total)
|
||||
else doc.base_grand_total,
|
||||
doc.precision("base_grand_total"),
|
||||
)
|
||||
if grand_total and not doc.is_internal_transfer():
|
||||
self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
|
||||
|
||||
def add_supplier_gl_entry(
|
||||
self,
|
||||
gl_entries,
|
||||
base_grand_total,
|
||||
grand_total,
|
||||
against_account=None,
|
||||
remarks=None,
|
||||
skip_merge=False,
|
||||
):
|
||||
doc = self.doc
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
|
||||
gl = {
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"due_date": doc.due_date,
|
||||
"against": against_account or doc.against_expense_account,
|
||||
"credit": base_grand_total,
|
||||
"credit_in_account_currency": base_grand_total
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else grand_total,
|
||||
"credit_in_transaction_currency": grand_total,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"project": doc.project,
|
||||
"cost_center": doc.cost_center,
|
||||
"_skip_merge": skip_merge,
|
||||
}
|
||||
if remarks:
|
||||
gl["remarks"] = remarks
|
||||
gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc))
|
||||
|
||||
def make_item_gl_entries(self, gl_entries):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
|
||||
get_custom_dimension_overrides,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
stock_items = doc.get_stock_items()
|
||||
if doc.update_stock and doc.auto_accounting_for_stock:
|
||||
inventory_account_map = doc.get_inventory_account_map()
|
||||
|
||||
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
|
||||
|
||||
voucher_wise_stock_value = {}
|
||||
if doc.update_stock:
|
||||
stock_ledger_entries = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
|
||||
filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0},
|
||||
)
|
||||
for d in stock_ledger_entries:
|
||||
voucher_wise_stock_value.setdefault(
|
||||
(d.voucher_detail_no, d.warehouse), d.stock_value_difference
|
||||
)
|
||||
|
||||
valuation_tax_accounts = [
|
||||
d.account_head
|
||||
for d in doc.get("taxes")
|
||||
if d.category in ("Valuation", "Valuation and Total")
|
||||
and flt(d.base_tax_amount_after_discount_amount)
|
||||
]
|
||||
|
||||
exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
|
||||
|
||||
provisional_accounting_for_non_stock_items = cint(
|
||||
frappe.get_cached_value(
|
||||
"Company", doc.company, "enable_provisional_accounting_for_non_stock_items"
|
||||
)
|
||||
)
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.get_provisional_accounts()
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for item in doc.get("items"):
|
||||
if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate):
|
||||
if item.item_code:
|
||||
frappe.get_cached_value("Item", item.item_code, "asset_category")
|
||||
|
||||
if (
|
||||
doc.update_stock
|
||||
and doc.auto_accounting_for_stock
|
||||
and (item.item_code in stock_items or item.is_fixed_asset)
|
||||
):
|
||||
account_currency = get_account_currency(item.expense_account)
|
||||
warehouse_debit_amount = self.make_stock_adjustment_entry(
|
||||
gl_entries, item, voucher_wise_stock_value, account_currency
|
||||
)
|
||||
|
||||
if item.from_warehouse:
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
_inv_dict_from_warehouse = doc.get_inventory_account_dict(
|
||||
item, inventory_account_map, "from_warehouse"
|
||||
)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": _inv_dict["account"],
|
||||
"against": _inv_dict_from_warehouse["account"],
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": warehouse_debit_amount,
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
_inv_dict["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
credit_amount = item.base_net_amount
|
||||
if doc.is_internal_supplier and item.valuation_rate:
|
||||
credit_amount = flt(item.valuation_rate * item.stock_qty)
|
||||
|
||||
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": _inv_dict_from_warehouse["account"],
|
||||
"against": _inv_dict["account"],
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
_inv_dict_from_warehouse["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": flt(item.base_net_amount, item.precision("base_net_amount")),
|
||||
"debit_in_transaction_currency": item.net_amount,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
if not doc.is_internal_transfer():
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": warehouse_debit_amount,
|
||||
"debit_in_transaction_currency": flt(
|
||||
warehouse_debit_amount / doc.conversion_rate,
|
||||
item.precision("net_amount"),
|
||||
),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# Amount added through landed-cost-voucher
|
||||
if landed_cost_entries:
|
||||
for entry in landed_cost_entries.get((item.item_code, item.name), []):
|
||||
if not (entry.amount or entry.base_amount):
|
||||
continue
|
||||
|
||||
gl_dict = self.get_gl_dict(
|
||||
{
|
||||
"account": entry.expense_account,
|
||||
"against": item.expense_account,
|
||||
"cost_center": entry.dimensions.cost_center or item.cost_center,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": flt(entry.base_amount),
|
||||
"credit_in_account_currency": flt(entry.amount),
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
"project": entry.dimensions.project or item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
gl_dict.update(get_custom_dimension_overrides(entry))
|
||||
gl_entries.append(gl_dict)
|
||||
|
||||
# sub-contracting warehouse
|
||||
if flt(item.rm_supp_cost):
|
||||
supplier_wh_dict = doc.get_inventory_account_dict(
|
||||
item, inventory_account_map, "supplier_warehouse"
|
||||
)
|
||||
supplier_inventory_account = supplier_wh_dict["account"]
|
||||
if not supplier_inventory_account:
|
||||
frappe.throw(
|
||||
_("Please set account in Warehouse {0}").format(doc.supplier_warehouse)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": supplier_inventory_account,
|
||||
"against": item.expense_account,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": flt(item.rm_supp_cost),
|
||||
"credit_in_transaction_currency": item.net_amount,
|
||||
},
|
||||
supplier_wh_dict["account_currency"],
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
expense_account = (
|
||||
item.expense_account
|
||||
if (not item.enable_deferred_expense or doc.is_return)
|
||||
else item.deferred_expense_account
|
||||
)
|
||||
account_currency = get_account_currency(expense_account)
|
||||
amount, base_amount = tax_service.get_amount_and_base_amount(item, None)
|
||||
|
||||
if provisional_accounting_for_non_stock_items:
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
# When Update Stock is disabled, this invoice has no stock impact: the linked
|
||||
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
|
||||
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
|
||||
# amount against the supplier - booking PPV again would double count it and leave
|
||||
# SRBNB partially uncleared.
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
not adjust_incoming_rate
|
||||
and item.get("purchase_receipt")
|
||||
and doc.auto_accounting_for_stock
|
||||
):
|
||||
if (
|
||||
exchange_rate_map[item.purchase_receipt]
|
||||
and doc.conversion_rate != exchange_rate_map[item.purchase_receipt]
|
||||
and item.net_rate == net_rate_map[item.pr_detail]
|
||||
and item.item_code in stock_items
|
||||
):
|
||||
discrepancy_caused_by_exchange_rate_difference = (
|
||||
item.qty * item.net_rate
|
||||
) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": discrepancy_caused_by_exchange_rate_difference,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.get_company_default("exchange_gain_loss_account"),
|
||||
"against": doc.supplier,
|
||||
"credit": discrepancy_caused_by_exchange_rate_difference,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
doc.auto_accounting_for_stock
|
||||
and doc.is_opening == "No"
|
||||
and item.item_code in stock_items
|
||||
and item.item_tax_amount
|
||||
):
|
||||
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
|
||||
if item.purchase_receipt and valuation_tax_accounts:
|
||||
negative_expense_booked_in_pr = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": ["in", valuation_tax_accounts],
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
(
|
||||
doc.get_company_default("asset_received_but_not_billed")
|
||||
if item.is_fixed_asset
|
||||
else doc.stock_received_but_not_billed
|
||||
)
|
||||
|
||||
if not negative_expense_booked_in_pr:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.stock_received_but_not_billed,
|
||||
"against": doc.supplier,
|
||||
"debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
|
||||
"debit_in_transaction_currency": flt(
|
||||
item.item_tax_amount / doc.conversion_rate,
|
||||
item.precision("item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
"cost_center": doc.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
doc.negative_expense_to_be_booked += flt(
|
||||
item.item_tax_amount, item.precision("item_tax_amount")
|
||||
)
|
||||
|
||||
if item.is_fixed_asset and item.landed_cost_voucher_amount:
|
||||
self.update_net_purchase_amount_for_linked_assets(item)
|
||||
|
||||
def get_provisional_accounts(self):
|
||||
doc = self.doc
|
||||
self.provisional_accounts = frappe._dict()
|
||||
linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt}
|
||||
if not linked_purchase_receipts:
|
||||
return
|
||||
|
||||
pr_items = frappe.get_all(
|
||||
"Purchase Receipt Item",
|
||||
filters={"parent": ("in", linked_purchase_receipts)},
|
||||
fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
|
||||
)
|
||||
default_provisional_account = doc.get_company_default("default_provisional_account")
|
||||
provisional_accounts = {
|
||||
d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
|
||||
for d in pr_items
|
||||
}
|
||||
|
||||
provisional_gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": ("in", linked_purchase_receipts),
|
||||
"account": ("in", provisional_accounts),
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
fields=["voucher_detail_no"],
|
||||
)
|
||||
rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
|
||||
for item in pr_items:
|
||||
self.provisional_accounts[item.name] = {
|
||||
"provisional_account": item.provisional_expense_account or default_provisional_account,
|
||||
"qty": item.qty,
|
||||
"base_rate": item.base_rate,
|
||||
"rate": item.rate,
|
||||
"has_provisional_entry": item.name in rows_with_provisional_entries,
|
||||
}
|
||||
|
||||
def make_provisional_gl_entry(self, gl_entries, item):
|
||||
if item.purchase_receipt:
|
||||
pr_item = self.provisional_accounts.get(item.pr_detail, {})
|
||||
if pr_item.get("has_provisional_entry"):
|
||||
purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
|
||||
|
||||
# Intentionally passing purchase invoice item to handle partial billing
|
||||
purchase_receipt_doc.add_provisional_gl_entry(
|
||||
item,
|
||||
gl_entries,
|
||||
self.doc.posting_date,
|
||||
pr_item.get("provisional_account"),
|
||||
reverse=1,
|
||||
item_amount=(
|
||||
(min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
|
||||
* purchase_receipt_doc.get("conversion_rate")
|
||||
),
|
||||
)
|
||||
|
||||
def update_net_purchase_amount_for_linked_assets(self, item):
|
||||
doc = self.doc
|
||||
assets = frappe.db.get_all(
|
||||
"Asset",
|
||||
filters={
|
||||
"purchase_invoice": doc.name,
|
||||
"item_code": item.item_code,
|
||||
"purchase_invoice_item": ("in", [item.name, ""]),
|
||||
},
|
||||
fields=["name", "asset_quantity"],
|
||||
)
|
||||
for asset in assets:
|
||||
purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
asset.name,
|
||||
{
|
||||
"net_purchase_amount": purchase_amount,
|
||||
"purchase_amount": purchase_amount,
|
||||
},
|
||||
)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""Return the account for stock valuation difference.
|
||||
Standard Cost items use the Purchase Price Variance account. Other items use
|
||||
the default expense account, falling back to the item expense account for
|
||||
returns and the stock/asset received but not billed account for non-returns."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
|
||||
return get_purchase_price_variance_account(item.item_code, self.doc.company)
|
||||
|
||||
# 1. Primary choice: Company Default Expense / COGS Account
|
||||
default_expense = self.doc.get_company_default("default_expense_account", ignore_validation=True)
|
||||
if default_expense:
|
||||
return default_expense
|
||||
|
||||
# 2. If default_expense_account is NOT set (Unconfigured):
|
||||
# For returns, fall back to item.expense_account
|
||||
if self.doc.is_return and item.expense_account:
|
||||
return item.expense_account
|
||||
|
||||
# For non-returns, fall back to the clearing account used by Purchase Receipts.
|
||||
stock_asset_rbnb = (
|
||||
self.doc.get_company_default("asset_received_but_not_billed", ignore_validation=True)
|
||||
if item.is_fixed_asset
|
||||
else self.doc.get_company_default("stock_received_but_not_billed", ignore_validation=True)
|
||||
)
|
||||
|
||||
return stock_asset_rbnb or item.expense_account
|
||||
|
||||
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
|
||||
doc = self.doc
|
||||
net_amt_precision = item.precision("base_net_amount")
|
||||
val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
|
||||
|
||||
warehouse_debit_amount = flt(
|
||||
flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
|
||||
net_amt_precision,
|
||||
)
|
||||
|
||||
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
|
||||
net_rate = item.base_net_amount
|
||||
if item.sales_incoming_rate:
|
||||
net_rate = item.qty * item.sales_incoming_rate
|
||||
|
||||
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
|
||||
warehouse_debit_amount = flt(
|
||||
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
|
||||
)
|
||||
|
||||
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_adjustment_amt = stock_amount - warehouse_debit_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": cost_of_goods_sold_account,
|
||||
"against": item.expense_account,
|
||||
"debit": stock_adjustment_amt,
|
||||
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
|
||||
"remarks": doc.get("remarks") or _("Stock Adjustment"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
elif (
|
||||
doc.update_stock
|
||||
and voucher_wise_stock_value.get((item.name, item.warehouse))
|
||||
and warehouse_debit_amount
|
||||
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
):
|
||||
cost_of_goods_sold_account = self.get_stock_variance_account(item)
|
||||
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
|
||||
stock_adjustment_amt = warehouse_debit_amount - stock_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": cost_of_goods_sold_account,
|
||||
"against": item.expense_account,
|
||||
"debit": stock_adjustment_amt,
|
||||
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
|
||||
"remarks": doc.get("remarks") or _("Stock Adjustment"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
warehouse_debit_amount = stock_amount
|
||||
|
||||
return warehouse_debit_amount
|
||||
|
||||
def make_tax_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
valuation_tax = {}
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name - a non-stock item's share of a spread-across-all-items charge is excluded.
|
||||
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
|
||||
|
||||
for tax in doc.get("taxes"):
|
||||
amount, base_amount = tax_service.get_tax_amounts(tax, None)
|
||||
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
|
||||
account_currency = get_account_currency(tax.account_head)
|
||||
dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"against": doc.supplier,
|
||||
dr_or_cr: base_amount,
|
||||
dr_or_cr + "_in_account_currency": base_amount
|
||||
if account_currency == doc.company_currency
|
||||
else amount,
|
||||
dr_or_cr + "_in_transaction_currency": amount,
|
||||
"cost_center": tax.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
doc.is_opening == "No"
|
||||
and tax.category in ("Valuation", "Valuation and Total")
|
||||
and flt(base_amount)
|
||||
and not doc.is_internal_transfer()
|
||||
):
|
||||
if doc.auto_accounting_for_stock and not tax.cost_center:
|
||||
frappe.throw(
|
||||
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
|
||||
total_valuation_amount = sum(valuation_tax.values())
|
||||
amount_including_divisional_loss = doc.negative_expense_to_be_booked
|
||||
i = 1
|
||||
for tax in doc.get("taxes"):
|
||||
if valuation_tax.get(tax.name):
|
||||
if i == len(valuation_tax):
|
||||
applicable_amount = amount_including_divisional_loss
|
||||
else:
|
||||
applicable_amount = doc.negative_expense_to_be_booked * (
|
||||
valuation_tax[tax.name] / total_valuation_amount
|
||||
)
|
||||
amount_including_divisional_loss -= applicable_amount
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"cost_center": tax.cost_center,
|
||||
"against": doc.supplier,
|
||||
"credit": applicable_amount,
|
||||
"credit_in_transaction_currency": flt(
|
||||
applicable_amount / doc.conversion_rate,
|
||||
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
},
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
|
||||
if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax:
|
||||
for tax in doc.get("taxes"):
|
||||
if valuation_tax.get(tax.name):
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": tax.account_head,
|
||||
"cost_center": tax.cost_center,
|
||||
"against": doc.supplier,
|
||||
"credit": valuation_tax[tax.name],
|
||||
"credit_in_transaction_currency": flt(
|
||||
valuation_tax[tax.name] / doc.conversion_rate,
|
||||
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
|
||||
),
|
||||
"remarks": doc.remarks or _("Accounting Entry for Stock"),
|
||||
},
|
||||
item=tax,
|
||||
)
|
||||
)
|
||||
|
||||
def make_internal_transfer_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
|
||||
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.unrealized_profit_loss_account,
|
||||
"against": doc.supplier,
|
||||
"credit": flt(doc.total_taxes_and_charges),
|
||||
"credit_in_transaction_currency": flt(doc.total_taxes_and_charges),
|
||||
"credit_in_account_currency": flt(doc.base_total_taxes_and_charges),
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_gl_entries_for_tax_withholding(self, gl_entries):
|
||||
"""Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount."""
|
||||
doc = self.doc
|
||||
if not doc.apply_tds:
|
||||
return
|
||||
|
||||
for row in doc.get("taxes"):
|
||||
if not row.is_tax_withholding_account or not row.tax_amount:
|
||||
continue
|
||||
|
||||
base_tds_amount = row.base_tax_amount_after_discount_amount
|
||||
tds_amount = row.tax_amount_after_discount_amount
|
||||
|
||||
self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
|
||||
self.add_supplier_gl_entry(
|
||||
gl_entries,
|
||||
-base_tds_amount,
|
||||
-tds_amount,
|
||||
against_account=row.account_head,
|
||||
remarks=_("TDS Deducted"),
|
||||
skip_merge=True,
|
||||
)
|
||||
|
||||
def make_payment_gl_entries(self, gl_entries):
|
||||
doc = self.doc
|
||||
if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount:
|
||||
against_voucher = doc.name
|
||||
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
|
||||
against_voucher = doc.return_against
|
||||
bank_account_currency = get_account_currency(doc.cash_bank_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"against": doc.cash_bank_account,
|
||||
"debit": doc.base_paid_amount,
|
||||
"debit_in_account_currency": doc.base_paid_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else doc.paid_amount,
|
||||
"debit_in_transaction_currency": doc.paid_amount,
|
||||
"against_voucher": against_voucher,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.cash_bank_account,
|
||||
"against": doc.supplier,
|
||||
"credit": doc.base_paid_amount,
|
||||
"credit_in_account_currency": doc.base_paid_amount
|
||||
if bank_account_currency == doc.company_currency
|
||||
else doc.paid_amount,
|
||||
"credit_in_transaction_currency": doc.paid_amount,
|
||||
"cost_center": doc.cost_center,
|
||||
},
|
||||
bank_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_write_off_gl_entry(self, gl_entries):
|
||||
doc = self.doc
|
||||
if doc.write_off_account and flt(doc.write_off_amount):
|
||||
write_off_account_currency = get_account_currency(doc.write_off_account)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.credit_to,
|
||||
"party_type": "Supplier",
|
||||
"party": doc.supplier,
|
||||
"against": doc.write_off_account,
|
||||
"debit": doc.base_write_off_amount,
|
||||
"debit_in_account_currency": doc.base_write_off_amount
|
||||
if doc.party_account_currency == doc.company_currency
|
||||
else doc.write_off_amount,
|
||||
"debit_in_transaction_currency": doc.write_off_amount,
|
||||
"against_voucher": doc.return_against
|
||||
if cint(doc.is_return) and doc.return_against
|
||||
else doc.name,
|
||||
"against_voucher_type": doc.doctype,
|
||||
"cost_center": doc.cost_center,
|
||||
"project": doc.project,
|
||||
},
|
||||
doc.party_account_currency,
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": doc.write_off_account,
|
||||
"against": doc.supplier,
|
||||
"credit": flt(doc.base_write_off_amount),
|
||||
"credit_in_account_currency": doc.base_write_off_amount
|
||||
if write_off_account_currency == doc.company_currency
|
||||
else doc.write_off_amount,
|
||||
"credit_in_transaction_currency": doc.write_off_amount,
|
||||
"cost_center": doc.cost_center or doc.write_off_cost_center,
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
|
||||
def make_gle_for_rounding_adjustment(self, gl_entries):
|
||||
doc = self.doc
|
||||
if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment:
|
||||
(
|
||||
round_off_account,
|
||||
round_off_cost_center,
|
||||
round_off_for_opening,
|
||||
) = get_round_off_account_and_cost_center(
|
||||
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
|
||||
)
|
||||
|
||||
if doc.is_opening == "Yes" and doc.rounding_adjustment:
|
||||
if not round_off_for_opening:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
|
||||
).format(
|
||||
frappe.bold(doc.rounding_adjustment),
|
||||
frappe.bold("Round Off for Opening"),
|
||||
get_link_to_form("Company", doc.company),
|
||||
frappe.bold("Disable Rounded Total"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
round_off_account = round_off_for_opening
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": round_off_account,
|
||||
"against": doc.supplier,
|
||||
"debit_in_account_currency": doc.rounding_adjustment,
|
||||
"debit": doc.base_rounding_adjustment,
|
||||
"cost_center": round_off_cost_center
|
||||
if doc.use_company_roundoff_cost_center
|
||||
else (doc.cost_center or round_off_cost_center),
|
||||
},
|
||||
item=doc,
|
||||
)
|
||||
)
|
||||
285
erpnext/accounts/services/base_gl_composer.py
Normal file
285
erpnext/accounts/services/base_gl_composer.py
Normal file
@@ -0,0 +1,285 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Base class and free functions for per-document GL entry composition.
|
||||
|
||||
``BaseGLComposer`` holds the document being composed and exposes
|
||||
``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic
|
||||
lives in the module-level free functions below (``doc`` as first argument), so
|
||||
``AccountsController`` and ``StockController`` can delegate to them via thin
|
||||
shims without forcing every GL-building doctype to inherit from those classes.
|
||||
|
||||
Subclasses implement ``compose`` to return the voucher-specific list of GL
|
||||
entries.
|
||||
"""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt, formatdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
|
||||
from erpnext.accounts.services.taxes import set_balance_in_account_currency
|
||||
from erpnext.accounts.utils import get_account_currency, get_fiscal_years
|
||||
from erpnext.utilities.regional import temporary_flag
|
||||
|
||||
|
||||
def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict:
|
||||
"""Build a GL entry dict populated with doc-level fields."""
|
||||
posting_date = args.get("posting_date") or doc.get("posting_date")
|
||||
fiscal_years = get_fiscal_years(posting_date, company=doc.company)
|
||||
if len(fiscal_years) > 1:
|
||||
frappe.throw(
|
||||
_("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format(
|
||||
formatdate(posting_date)
|
||||
)
|
||||
)
|
||||
else:
|
||||
fiscal_year = fiscal_years[0][0]
|
||||
|
||||
gl_dict = frappe._dict(
|
||||
{
|
||||
"company": doc.company,
|
||||
"posting_date": posting_date,
|
||||
"fiscal_year": fiscal_year,
|
||||
"voucher_type": doc.doctype,
|
||||
"voucher_no": doc.name,
|
||||
"remarks": doc.get("remarks") or doc.get("remark"),
|
||||
"debit": 0,
|
||||
"credit": 0,
|
||||
"debit_in_account_currency": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"is_opening": doc.get("is_opening") or "No",
|
||||
"party_type": None,
|
||||
"party": None,
|
||||
"project": doc.get("project"),
|
||||
"post_net_value": args.get("post_net_value"),
|
||||
"voucher_detail_no": args.get("voucher_detail_no"),
|
||||
"voucher_subtype": get_voucher_subtype(doc),
|
||||
}
|
||||
)
|
||||
|
||||
with temporary_flag("company", doc.company):
|
||||
update_gl_dict_with_regional_fields(doc, gl_dict)
|
||||
|
||||
update_gl_dict_with_app_based_fields(doc, gl_dict)
|
||||
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
dimension_dict = frappe._dict()
|
||||
for dimension in accounting_dimensions:
|
||||
value = doc.get(dimension)
|
||||
if item and item.get(dimension):
|
||||
value = item.get(dimension)
|
||||
if isinstance(value, list | dict):
|
||||
continue
|
||||
dimension_dict[dimension] = value
|
||||
|
||||
gl_dict.update(dimension_dict)
|
||||
gl_dict.update(args)
|
||||
|
||||
if not account_currency:
|
||||
account_currency = get_account_currency(gl_dict.account)
|
||||
|
||||
if gl_dict.account and doc.doctype not in [
|
||||
"Journal Entry",
|
||||
"Period Closing Voucher",
|
||||
"Payment Entry",
|
||||
"Purchase Receipt",
|
||||
"Purchase Invoice",
|
||||
"Stock Entry",
|
||||
]:
|
||||
validate_account_currency(doc, gl_dict.account, account_currency)
|
||||
|
||||
if gl_dict.account and doc.doctype not in [
|
||||
"Journal Entry",
|
||||
"Period Closing Voucher",
|
||||
"Payment Entry",
|
||||
]:
|
||||
set_balance_in_account_currency(
|
||||
gl_dict,
|
||||
account_currency,
|
||||
args.get("transaction_exchange_rate") or doc.get("conversion_rate"),
|
||||
doc.company_currency,
|
||||
)
|
||||
|
||||
if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]:
|
||||
gl_dict.update(
|
||||
{
|
||||
"transaction_currency": doc.get("currency") or doc.company_currency,
|
||||
"transaction_exchange_rate": args.get("transaction_exchange_rate")
|
||||
or doc.get("conversion_rate", 1),
|
||||
"debit_in_transaction_currency": get_value_in_transaction_currency(
|
||||
doc, account_currency, gl_dict, "debit"
|
||||
),
|
||||
"credit_in_transaction_currency": get_value_in_transaction_currency(
|
||||
doc, account_currency, gl_dict, "credit"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if not args.get("against_voucher_type") and doc.get("against_voucher_type"):
|
||||
gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")})
|
||||
|
||||
if not args.get("against_voucher") and doc.get("against_voucher"):
|
||||
gl_dict.update({"against_voucher": doc.get("against_voucher")})
|
||||
|
||||
return gl_dict
|
||||
|
||||
|
||||
def add_gl_entry(
|
||||
doc,
|
||||
gl_entries: list,
|
||||
account: str,
|
||||
cost_center: str,
|
||||
debit: float,
|
||||
credit: float,
|
||||
remarks: str,
|
||||
against_account: str,
|
||||
debit_in_account_currency: float | None = None,
|
||||
credit_in_account_currency: float | None = None,
|
||||
account_currency: str | None = None,
|
||||
project: str | None = None,
|
||||
voucher_detail_no: str | None = None,
|
||||
item=None,
|
||||
posting_date=None,
|
||||
dimensions: dict | None = None,
|
||||
) -> None:
|
||||
"""Build a GL entry via get_gl_dict and append it to gl_entries.
|
||||
|
||||
`dimensions` sets accounting dimensions explicitly, overriding the values `get_gl_dict`
|
||||
would otherwise derive from `item` and the parent document.
|
||||
"""
|
||||
gl_entry = {
|
||||
"account": account,
|
||||
"cost_center": cost_center,
|
||||
"debit": debit,
|
||||
"credit": credit,
|
||||
"against": against_account,
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
if project:
|
||||
gl_entry["project"] = project
|
||||
|
||||
if voucher_detail_no:
|
||||
gl_entry["voucher_detail_no"] = voucher_detail_no
|
||||
|
||||
if debit_in_account_currency:
|
||||
gl_entry["debit_in_account_currency"] = debit_in_account_currency
|
||||
|
||||
if credit_in_account_currency:
|
||||
gl_entry["credit_in_account_currency"] = credit_in_account_currency
|
||||
|
||||
if posting_date:
|
||||
gl_entry["posting_date"] = posting_date
|
||||
|
||||
if dimensions:
|
||||
gl_entry.update(dimensions)
|
||||
|
||||
gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item))
|
||||
|
||||
|
||||
def get_voucher_subtype(doc) -> str:
|
||||
voucher_subtypes = {
|
||||
"Journal Entry": "voucher_type",
|
||||
"Payment Entry": "payment_type",
|
||||
"Stock Entry": "stock_entry_type",
|
||||
"Asset Capitalization": "entry_type",
|
||||
}
|
||||
|
||||
for method_name in frappe.get_hooks("voucher_subtypes"):
|
||||
voucher_subtype = frappe.get_attr(method_name)(doc)
|
||||
if voucher_subtype:
|
||||
return voucher_subtype
|
||||
|
||||
if doc.doctype in voucher_subtypes:
|
||||
return doc.get(voucher_subtypes[doc.doctype])
|
||||
elif doc.doctype == "Purchase Receipt" and doc.is_return:
|
||||
return "Purchase Return"
|
||||
elif doc.doctype == "Delivery Note" and doc.is_return:
|
||||
return "Sales Return"
|
||||
elif doc.doctype == "Sales Invoice" and doc.is_return:
|
||||
return "Credit Note"
|
||||
elif doc.doctype == "Sales Invoice" and doc.is_debit_note:
|
||||
return "Debit Note"
|
||||
elif doc.doctype == "Purchase Invoice" and doc.is_return:
|
||||
return "Debit Note"
|
||||
|
||||
return doc.doctype
|
||||
|
||||
|
||||
def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float:
|
||||
if account_currency == doc.get("currency"):
|
||||
return gl_dict.get(field + "_in_account_currency")
|
||||
return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1))
|
||||
|
||||
|
||||
def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None:
|
||||
valid_currency = [doc.company_currency]
|
||||
if doc.get("currency") and doc.currency != doc.company_currency:
|
||||
valid_currency.append(doc.currency)
|
||||
|
||||
if account_currency not in valid_currency:
|
||||
frappe.throw(
|
||||
_("Account {0} is invalid. Account Currency must be {1}").format(
|
||||
account, (" " + _("or") + " ").join(valid_currency)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def update_gl_dict_with_regional_fields(doc, gl_dict):
|
||||
pass
|
||||
|
||||
|
||||
def update_gl_dict_with_app_based_fields(doc, gl_dict):
|
||||
for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]):
|
||||
frappe.get_attr(method)(doc, gl_dict)
|
||||
|
||||
|
||||
class BaseGLComposer:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def compose(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict:
|
||||
return get_gl_dict(self.doc, args, account_currency, item)
|
||||
|
||||
def add_gl_entry(
|
||||
self,
|
||||
gl_entries: list,
|
||||
account: str,
|
||||
cost_center: str,
|
||||
debit: float,
|
||||
credit: float,
|
||||
remarks: str,
|
||||
against_account: str,
|
||||
debit_in_account_currency: float | None = None,
|
||||
credit_in_account_currency: float | None = None,
|
||||
account_currency: str | None = None,
|
||||
project: str | None = None,
|
||||
voucher_detail_no: str | None = None,
|
||||
item=None,
|
||||
posting_date=None,
|
||||
dimensions: dict | None = None,
|
||||
) -> None:
|
||||
add_gl_entry(
|
||||
self.doc,
|
||||
gl_entries,
|
||||
account,
|
||||
cost_center,
|
||||
debit,
|
||||
credit,
|
||||
remarks,
|
||||
against_account,
|
||||
debit_in_account_currency,
|
||||
credit_in_account_currency,
|
||||
account_currency,
|
||||
project,
|
||||
voucher_detail_no,
|
||||
item,
|
||||
posting_date,
|
||||
dimensions,
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
457
erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
Normal file
457
erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
Normal file
@@ -0,0 +1,457 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.general_ledger import process_gl_map
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
from erpnext.stock import get_warehouse_account
|
||||
from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
|
||||
|
||||
|
||||
class PurchaseReceiptGLComposer(BaseStockGLComposer):
|
||||
"""GL composer for Purchase Receipt.
|
||||
|
||||
Builds GL entries for stock/asset inward, taxes, purchase expense, and
|
||||
regional adjustments. Does not delegate to the base stock GL loop —
|
||||
PR has its own per-item logic (provisional accounting, fixed assets, LCV,
|
||||
sub-contracting, divisional loss).
|
||||
"""
|
||||
|
||||
def compose(
|
||||
self,
|
||||
inventory_account_map: dict | None = None,
|
||||
via_landed_cost_voucher: bool = False,
|
||||
) -> list:
|
||||
gl_entries = []
|
||||
self._make_item_gl_entries(gl_entries, inventory_account_map)
|
||||
self._make_tax_gl_entries(gl_entries, via_landed_cost_voucher)
|
||||
self.doc.set_gl_entry_for_purchase_expense(gl_entries)
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_regional_gl_entries
|
||||
|
||||
update_regional_gl_entries(gl_entries, self.doc)
|
||||
|
||||
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
|
||||
|
||||
def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None:
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
|
||||
get_custom_dimension_overrides,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_stock_value_difference
|
||||
|
||||
doc = self.doc
|
||||
provisional_accounting_for_non_stock_items = cint(
|
||||
frappe.db.get_value("Company", doc.company, "enable_provisional_accounting_for_non_stock_items")
|
||||
)
|
||||
|
||||
exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
|
||||
stock_items = doc.get_stock_items()
|
||||
warehouse_with_no_account = []
|
||||
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
|
||||
|
||||
def validate_account(account_type):
|
||||
frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type))
|
||||
|
||||
def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name):
|
||||
account_currency = get_account_currency(stock_asset_account_name)
|
||||
if not stock_asset_account_name:
|
||||
validate_account("Asset or warehouse account")
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=stock_asset_account_name,
|
||||
cost_center=d.cost_center,
|
||||
debit=stock_value_diff,
|
||||
credit=0.0,
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_rbnb,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
def make_stock_received_but_not_billed_entry(item):
|
||||
if (
|
||||
doc.get("is_return")
|
||||
and item.return_qty_from_rejected_warehouse
|
||||
and not frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
)
|
||||
):
|
||||
return 0.0
|
||||
|
||||
account = stock_asset_rbnb
|
||||
if item.from_warehouse:
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "from_warehouse")
|
||||
account = _inv_dict["account"]
|
||||
|
||||
account_currency = get_account_currency(account)
|
||||
|
||||
credit_amount = (
|
||||
flt(item.base_net_amount, item.precision("base_net_amount"))
|
||||
if account_currency == doc.company_currency
|
||||
else flt(item.net_amount, item.precision("net_amount"))
|
||||
)
|
||||
|
||||
outgoing_amount = item.base_net_amount
|
||||
if doc.is_internal_transfer() and item.valuation_rate:
|
||||
outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
|
||||
credit_amount = outgoing_amount
|
||||
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
|
||||
credit_amount = outgoing_amount
|
||||
|
||||
if credit_amount:
|
||||
if not account:
|
||||
validate_account("Stock or Asset Received But Not Billed")
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=account,
|
||||
cost_center=item.cost_center,
|
||||
debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
|
||||
credit=0.0,
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_account_name,
|
||||
debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
if d.get("purchase_invoice"):
|
||||
if (
|
||||
exchange_rate_map[item.purchase_invoice]
|
||||
and doc.conversion_rate != exchange_rate_map[item.purchase_invoice]
|
||||
and item.net_rate == net_rate_map[item.purchase_invoice_item]
|
||||
):
|
||||
discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * (
|
||||
exchange_rate_map[item.purchase_invoice] - doc.conversion_rate
|
||||
)
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=account,
|
||||
cost_center=item.cost_center,
|
||||
debit=0.0,
|
||||
credit=discrepancy_caused_by_exchange_rate_difference,
|
||||
remarks=remarks,
|
||||
against_account=doc.supplier,
|
||||
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=doc.get_company_default("exchange_gain_loss_account"),
|
||||
cost_center=d.cost_center,
|
||||
debit=discrepancy_caused_by_exchange_rate_difference,
|
||||
credit=0.0,
|
||||
remarks=remarks,
|
||||
against_account=doc.supplier,
|
||||
debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
return outgoing_amount
|
||||
|
||||
def make_landed_cost_gl_entries(item):
|
||||
if not (item.landed_cost_voucher_amount and landed_cost_entries):
|
||||
return
|
||||
|
||||
for entry in landed_cost_entries.get((item.item_code, item.name), []):
|
||||
if not (entry.amount or entry.base_amount):
|
||||
continue
|
||||
|
||||
account = entry.expense_account
|
||||
if not account:
|
||||
validate_account("Landed Cost Account")
|
||||
|
||||
account_currency = get_account_currency(account)
|
||||
credit_amount = (
|
||||
flt(entry.base_amount)
|
||||
if (entry.base_amount or account_currency != doc.company_currency)
|
||||
else flt(entry.amount)
|
||||
)
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=account,
|
||||
cost_center=entry.dimensions.cost_center or item.cost_center,
|
||||
debit=0.0,
|
||||
credit=credit_amount,
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_account_name,
|
||||
credit_in_account_currency=flt(entry.amount),
|
||||
account_currency=account_currency,
|
||||
project=entry.dimensions.project or item.project,
|
||||
item=item,
|
||||
dimensions=get_custom_dimension_overrides(entry),
|
||||
)
|
||||
|
||||
def make_expenses_added_to_stock_entries(item):
|
||||
if not self.book_stock_expense_enabled():
|
||||
return
|
||||
|
||||
amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount"))
|
||||
if amount and not item.is_fixed_asset:
|
||||
self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item)
|
||||
|
||||
def make_amount_difference_entry(item):
|
||||
if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
|
||||
account_currency = get_account_currency(stock_asset_rbnb)
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=stock_asset_rbnb,
|
||||
cost_center=item.cost_center,
|
||||
debit=0.0,
|
||||
credit=flt(item.amount_difference_with_purchase_invoice),
|
||||
remarks=_("Adjustment based on Purchase Invoice rate"),
|
||||
against_account=stock_asset_account_name,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
def make_sub_contracting_gl_entries(item):
|
||||
if flt(item.rm_supp_cost) and supplier_warehouse_account:
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=supplier_warehouse_account,
|
||||
cost_center=item.cost_center,
|
||||
debit=0.0,
|
||||
credit=flt(item.rm_supp_cost),
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_account_name,
|
||||
account_currency=supplier_warehouse_account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
def make_divisional_loss_gl_entry(item, outgoing_amount):
|
||||
if item.is_fixed_asset:
|
||||
return
|
||||
|
||||
valuation_amount_as_per_doc = (
|
||||
flt(outgoing_amount, d.precision("base_net_amount"))
|
||||
+ flt(item.landed_cost_voucher_amount)
|
||||
+ flt(item.rm_supp_cost)
|
||||
+ flt(item.item_tax_amount)
|
||||
+ flt(item.amount_difference_with_purchase_invoice)
|
||||
)
|
||||
|
||||
divisional_loss = flt(
|
||||
valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
|
||||
)
|
||||
|
||||
if item.get("rejected_qty") and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
|
||||
divisional_loss -= rejected_item_cost
|
||||
|
||||
if divisional_loss:
|
||||
loss_account = self.get_divisional_loss_account(item, stock_asset_rbnb)
|
||||
|
||||
cost_center = item.cost_center or frappe.get_cached_value(
|
||||
"Company", doc.company, "cost_center"
|
||||
)
|
||||
account_currency = get_account_currency(loss_account)
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=loss_account,
|
||||
cost_center=cost_center,
|
||||
debit=divisional_loss,
|
||||
credit=0.0,
|
||||
remarks=remarks,
|
||||
against_account=stock_asset_account_name,
|
||||
account_currency=account_currency,
|
||||
project=item.project,
|
||||
item=item,
|
||||
)
|
||||
|
||||
for d in doc.get("items"):
|
||||
remarks = doc.get("remarks") or _("Accounting Entry for {0}").format(
|
||||
"Asset" if d.is_fixed_asset else "Stock"
|
||||
)
|
||||
|
||||
if (
|
||||
provisional_accounting_for_non_stock_items
|
||||
and d.item_code not in stock_items
|
||||
and flt(d.qty)
|
||||
and d.get("provisional_expense_account")
|
||||
and not d.is_fixed_asset
|
||||
):
|
||||
doc.add_provisional_gl_entry(
|
||||
d, gl_entries, doc.posting_date, d.get("provisional_expense_account")
|
||||
)
|
||||
elif flt(d.qty) and (flt(d.valuation_rate) or doc.is_return):
|
||||
if not (
|
||||
(erpnext.is_perpetual_inventory_enabled(doc.company) and d.item_code in stock_items)
|
||||
or (d.is_fixed_asset and not d.purchase_invoice)
|
||||
):
|
||||
continue
|
||||
|
||||
stock_asset_rbnb = (
|
||||
doc.get_company_default("asset_received_but_not_billed")
|
||||
if d.is_fixed_asset
|
||||
else doc.get_company_default("stock_received_but_not_billed")
|
||||
)
|
||||
if d.is_fixed_asset:
|
||||
stock_asset_account_name = d.expense_account
|
||||
stock_value_diff = (
|
||||
flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount)
|
||||
)
|
||||
elif inventory_account := doc.get_inventory_account_dict(d, inventory_account_map):
|
||||
stock_value_diff = get_stock_value_difference(doc.name, d.name, d.warehouse)
|
||||
stock_asset_account_name = inventory_account["account"]
|
||||
|
||||
supplier_warehouse_account = None
|
||||
supplier_warehouse_account_currency = None
|
||||
if doc.supplier_warehouse:
|
||||
supplier_warehouse_account = get_warehouse_account(
|
||||
frappe.get_cached_doc("Warehouse", doc.supplier_warehouse),
|
||||
raise_error=bool(flt(d.rm_supp_cost)),
|
||||
)
|
||||
if supplier_warehouse_account:
|
||||
supplier_warehouse_account_currency = get_account_currency(
|
||||
supplier_warehouse_account
|
||||
)
|
||||
|
||||
if (
|
||||
flt(stock_value_diff) == flt(d.rm_supp_cost)
|
||||
and supplier_warehouse_account
|
||||
and stock_asset_account_name == supplier_warehouse_account
|
||||
):
|
||||
continue
|
||||
|
||||
if (flt(d.valuation_rate) or doc.is_return or d.is_fixed_asset) and flt(d.qty):
|
||||
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
|
||||
outgoing_amount = make_stock_received_but_not_billed_entry(d)
|
||||
make_landed_cost_gl_entries(d)
|
||||
make_expenses_added_to_stock_entries(d)
|
||||
make_amount_difference_entry(d)
|
||||
make_sub_contracting_gl_entries(d)
|
||||
make_divisional_loss_gl_entry(d, outgoing_amount)
|
||||
elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
|
||||
not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
|
||||
and d.rejected_warehouse
|
||||
and d.rejected_warehouse not in warehouse_with_no_account
|
||||
):
|
||||
warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse)
|
||||
|
||||
if d.is_fixed_asset and d.landed_cost_voucher_amount:
|
||||
doc.update_assets(d, d.valuation_rate)
|
||||
|
||||
if d.rejected_qty and frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
):
|
||||
stock_asset_rbnb = (
|
||||
doc.get_company_default("asset_received_but_not_billed")
|
||||
if d.is_fixed_asset
|
||||
else doc.get_company_default("stock_received_but_not_billed")
|
||||
)
|
||||
|
||||
stock_value_diff = get_stock_value_difference(doc.name, d.name, d.rejected_warehouse)
|
||||
_inv_dict = doc.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse")
|
||||
stock_asset_account_name = _inv_dict["account"]
|
||||
|
||||
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
|
||||
if not d.qty:
|
||||
make_stock_received_but_not_billed_entry(d)
|
||||
|
||||
if warehouse_with_no_account:
|
||||
frappe.msgprint(
|
||||
_("No accounting entries for the following warehouses")
|
||||
+ ": \n"
|
||||
+ "\n".join(warehouse_with_no_account)
|
||||
)
|
||||
|
||||
def get_divisional_loss_account(self, item, stock_asset_rbnb):
|
||||
"""Account that absorbs the difference between the document value and the value actually
|
||||
booked into stock. For a Standard Cost item this difference is a purchase price variance
|
||||
(receipt rate vs standard rate), so it goes to the Purchase Price Variance account; for all
|
||||
other items it keeps the existing behaviour (default expense account, or the item's expense
|
||||
account on a return)."""
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
doc = self.doc
|
||||
if item.item_code and get_valuation_method(item.item_code, doc.company) == "Standard Cost":
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
return get_purchase_price_variance_account(item.item_code, doc.company)
|
||||
|
||||
loss_account = (
|
||||
doc.get_company_default("default_expense_account", ignore_validation=True) or stock_asset_rbnb
|
||||
)
|
||||
if doc.is_return and item.expense_account:
|
||||
loss_account = item.expense_account
|
||||
|
||||
return loss_account
|
||||
|
||||
def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None:
|
||||
doc = self.doc
|
||||
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")])
|
||||
|
||||
# Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by
|
||||
# tax row name. This is what must be credited to each tax account - a non-stock item's share
|
||||
# of a spread-across-all-items charge is not capitalized, so it is excluded here.
|
||||
capitalized_valuation_tax = doc.get_capitalized_valuation_tax()
|
||||
|
||||
valuation_tax = {}
|
||||
for tax in doc.get("taxes"):
|
||||
if tax.category in ("Valuation", "Valuation and Total") and flt(
|
||||
tax.base_tax_amount_after_discount_amount
|
||||
):
|
||||
if not tax.cost_center:
|
||||
frappe.throw(
|
||||
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
|
||||
tax.idx, _(tax.category)
|
||||
)
|
||||
)
|
||||
|
||||
valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0)
|
||||
|
||||
if negative_expense_to_be_booked and valuation_tax:
|
||||
against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0])
|
||||
total_valuation_amount = sum(valuation_tax.values())
|
||||
amount_including_divisional_loss = negative_expense_to_be_booked
|
||||
i = 1
|
||||
for tax in doc.get("taxes"):
|
||||
if valuation_tax.get(tax.name):
|
||||
account = tax.account_head
|
||||
if i == len(valuation_tax):
|
||||
applicable_amount = amount_including_divisional_loss
|
||||
else:
|
||||
applicable_amount = negative_expense_to_be_booked * (
|
||||
valuation_tax[tax.name] / total_valuation_amount
|
||||
)
|
||||
amount_including_divisional_loss -= applicable_amount
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=account,
|
||||
cost_center=tax.cost_center,
|
||||
debit=0.0,
|
||||
credit=applicable_amount,
|
||||
remarks=doc.remarks or _("Accounting Entry for Stock"),
|
||||
against_account=against_accounts,
|
||||
item=tax,
|
||||
)
|
||||
|
||||
i += 1
|
||||
336
erpnext/stock/doctype/stock_entry/services/gl_composer.py
Normal file
336
erpnext/stock/doctype/stock_entry/services/gl_composer.py
Normal file
@@ -0,0 +1,336 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.general_ledger import process_gl_map
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
|
||||
|
||||
|
||||
class StockEntryGLComposer(BaseStockGLComposer):
|
||||
"""GL composer for Stock Entry.
|
||||
|
||||
Extends the base stock GL loop with additional-cost entries (from the
|
||||
``additional_costs`` child table) and landed-cost voucher adjustments.
|
||||
The difference is posted to warehouse/balance-sheet accounts, so P&L
|
||||
enforcement on the expense account is off.
|
||||
"""
|
||||
|
||||
enforce_pl_expense_account = False
|
||||
book_expenses_added_to_stock = True
|
||||
|
||||
def compose(self, inventory_account_map: dict | None = None) -> list:
|
||||
doc = self.doc
|
||||
gl_entries = super().compose(inventory_account_map)
|
||||
|
||||
if doc.purpose in ("Repack", "Manufacture"):
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
|
||||
else:
|
||||
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
|
||||
|
||||
divide_based_on = total_basic_amount
|
||||
if doc.get("additional_costs") and not total_basic_amount:
|
||||
divide_based_on = sum(item.qty for item in doc.get("items"))
|
||||
|
||||
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
|
||||
total_basic_amount, divide_based_on
|
||||
)
|
||||
if item_account_wise_additional_cost:
|
||||
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
|
||||
|
||||
self._append_lcv_gl_entries(gl_entries, inventory_account_map)
|
||||
|
||||
if doc.purpose in ("Repack", "Manufacture"):
|
||||
self._append_manufacturing_variance_gl_entries(gl_entries, inventory_account_map)
|
||||
elif doc.purpose == "Material Receipt":
|
||||
self._append_receipt_variance_gl_entries(gl_entries)
|
||||
|
||||
return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
|
||||
|
||||
def _append_manufacturing_variance_gl_entries(
|
||||
self, gl_entries: list, inventory_account_map: dict
|
||||
) -> None:
|
||||
"""For Standard Cost finished goods produced via Manufacture/Repack, stock is booked at the item's
|
||||
standard rate, while the entry consumes raw-material (plus additional/landed) cost. The difference
|
||||
is a manufacturing variance and is reclassified from the finished good's expense account to the
|
||||
Manufacturing Variance account (mirrors Purchase Price Variance on a Purchase Receipt)."""
|
||||
precision = self.get_debit_field_precision()
|
||||
# Reuse the SLE map the base composer already fetched in compose() to avoid a second identical query.
|
||||
sle_map = self._sle_map
|
||||
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_manufacturing_variance_account,
|
||||
)
|
||||
|
||||
for d in self.doc.get("items"):
|
||||
variance = self._get_finished_good_variance(d, sle_map, precision)
|
||||
if variance:
|
||||
account = get_manufacturing_variance_account(d.item_code, self.doc.company)
|
||||
remarks = self.doc.get("remarks") or _("Manufacturing Variance for {0}").format(d.item_code)
|
||||
self._append_standard_cost_variance_pair(
|
||||
gl_entries, d, variance, account, remarks, inventory_account_map
|
||||
)
|
||||
|
||||
def _append_receipt_variance_gl_entries(self, gl_entries: list) -> None:
|
||||
"""For a Standard Cost item received via Material Receipt, stock is booked at the item's standard
|
||||
rate while the row may carry a manually-set basic rate plus additional/landed cost. The gap
|
||||
between that intended cost and the standard value is a purchase price variance, reclassified from
|
||||
the item's expense account to the Purchase Price Variance account."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
precision = self.get_debit_field_precision()
|
||||
sle_map = self._sle_map
|
||||
|
||||
for d in self.doc.get("items"):
|
||||
variance = self._get_receipt_variance(d, sle_map, precision)
|
||||
if variance:
|
||||
account = get_purchase_price_variance_account(d.item_code, self.doc.company)
|
||||
remarks = self.doc.get("remarks") or _("Purchase Price Variance for {0}").format(d.item_code)
|
||||
self._append_standard_cost_variance_pair(gl_entries, d, variance, account, remarks)
|
||||
|
||||
def _get_receipt_variance(self, item, sle_map, precision) -> float:
|
||||
"""Purchase price variance for a Standard Cost item on a Material Receipt: the gap between the full
|
||||
computed incoming cost (basic amount + additional cost + LCV, i.e. ``amount``) and the standard
|
||||
value booked into stock. 0 for anything that is not a plain Standard Cost receipt row."""
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if not item.t_warehouse or item.s_warehouse:
|
||||
return 0.0
|
||||
|
||||
if (
|
||||
item.get("is_finished_item")
|
||||
or item.get("secondary_item_type")
|
||||
or item.get("is_legacy_scrap_item")
|
||||
):
|
||||
return 0.0
|
||||
|
||||
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
|
||||
return 0.0
|
||||
|
||||
standard_value = sum(
|
||||
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
|
||||
)
|
||||
|
||||
return flt(flt(item.amount) - standard_value, precision)
|
||||
|
||||
def _get_finished_good_variance(self, item, sle_map, precision) -> float:
|
||||
"""Manufacturing variance for a Standard Cost finished good: the gap between the full computed
|
||||
incoming cost (raw-material share + additional cost + LCV, i.e. ``amount``) and the standard value
|
||||
actually booked into stock. Positive = consumed more than standard (unfavorable). 0 for anything
|
||||
that is not a Standard Cost finished good."""
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if not item.is_finished_item or not item.t_warehouse:
|
||||
return 0.0
|
||||
|
||||
if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost":
|
||||
return 0.0
|
||||
|
||||
# Value actually booked into stock for this finished good = qty * standard rate.
|
||||
standard_value = sum(
|
||||
flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0
|
||||
)
|
||||
|
||||
return flt(flt(item.amount) - standard_value, precision)
|
||||
|
||||
def _append_standard_cost_variance_pair(
|
||||
self,
|
||||
gl_entries: list,
|
||||
item,
|
||||
variance: float,
|
||||
variance_account: str,
|
||||
remarks: str,
|
||||
inventory_account_map: dict | None = None,
|
||||
) -> None:
|
||||
"""Reclassify ``variance`` from the item's expense account to the given variance account,
|
||||
restoring the expense account to the value it would carry without Standard Cost."""
|
||||
doc = self.doc
|
||||
cost_center = item.cost_center or frappe.get_cached_value("Company", doc.company, "cost_center")
|
||||
project = item.project or doc.get("project")
|
||||
|
||||
inventory_account = None
|
||||
if inventory_account_map:
|
||||
inventory_account = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")[
|
||||
"account"
|
||||
]
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": variance_account,
|
||||
"against": item.expense_account,
|
||||
"cost_center": cost_center,
|
||||
"remarks": remarks,
|
||||
"debit": variance,
|
||||
"project": project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": inventory_account or variance_account,
|
||||
"cost_center": cost_center,
|
||||
"remarks": remarks,
|
||||
"debit": -1 * variance,
|
||||
"project": project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
def _build_additional_cost_per_item_account(
|
||||
self, total_basic_amount: float, divide_based_on: float
|
||||
) -> dict:
|
||||
doc = self.doc
|
||||
item_account_wise_additional_cost = {}
|
||||
|
||||
for t in doc.get("additional_costs"):
|
||||
for d in doc.get("items"):
|
||||
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
|
||||
continue
|
||||
elif not d.t_warehouse:
|
||||
continue
|
||||
|
||||
item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
|
||||
item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
|
||||
t.expense_account, {"amount": 0.0, "base_amount": 0.0}
|
||||
)
|
||||
|
||||
multiply_based_on = d.basic_amount if total_basic_amount else d.qty
|
||||
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
|
||||
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
|
||||
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
|
||||
|
||||
return item_account_wise_additional_cost
|
||||
|
||||
def get_valuation_method(self, item_code: str) -> str:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
return get_valuation_method(item_code, self.doc.company)
|
||||
|
||||
def _append_additional_cost_gl_entries(
|
||||
self, gl_entries: list, item_account_wise_additional_cost: dict
|
||||
) -> None:
|
||||
doc = self.doc
|
||||
precision = self.get_debit_field_precision()
|
||||
|
||||
for d in doc.get("items"):
|
||||
for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items():
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
amount["amount"] = flt(amount["amount"], precision)
|
||||
amount["base_amount"] = flt(amount["base_amount"], precision)
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": account,
|
||||
"against": d.expense_account,
|
||||
"cost_center": d.cost_center,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit_in_account_currency": flt(amount["amount"]),
|
||||
"credit": flt(amount["base_amount"]),
|
||||
},
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
if self.get_valuation_method(d.item_code) == "Standard Cost":
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.expense_account,
|
||||
"against": account,
|
||||
"cost_center": d.cost_center,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"debit": flt(amount["base_amount"]),
|
||||
},
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": d.expense_account,
|
||||
"against": account,
|
||||
"cost_center": d.cost_center,
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"credit": -1 * flt(amount["base_amount"]),
|
||||
},
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
|
||||
def _append_lcv_gl_entries(self, gl_entries: list, inventory_account_map: dict) -> None:
|
||||
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
|
||||
get_custom_dimension_overrides,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
|
||||
if not landed_cost_entries:
|
||||
return
|
||||
|
||||
for item in doc.get("items"):
|
||||
if item.s_warehouse:
|
||||
continue
|
||||
|
||||
for entry in landed_cost_entries.get((item.item_code, item.name), []):
|
||||
if not (entry.amount or entry.base_amount):
|
||||
continue
|
||||
|
||||
account_currency = get_account_currency(entry.expense_account)
|
||||
credit_amount = (
|
||||
flt(entry.base_amount)
|
||||
if (entry.base_amount or account_currency != doc.company_currency)
|
||||
else flt(entry.amount)
|
||||
)
|
||||
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")
|
||||
gl_dict = self.get_gl_dict(
|
||||
{
|
||||
"account": entry.expense_account,
|
||||
"against": _inv_dict["account"],
|
||||
"cost_center": entry.dimensions.cost_center or item.cost_center,
|
||||
"debit": 0.0,
|
||||
"credit": credit_amount,
|
||||
"remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name),
|
||||
"credit_in_account_currency": flt(entry.amount),
|
||||
"account_currency": account_currency,
|
||||
"project": entry.dimensions.project or item.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
gl_dict.update(get_custom_dimension_overrides(entry))
|
||||
gl_entries.append(gl_dict)
|
||||
|
||||
# Reclass leg: keeps the item's dimensions so it nets against the base item entry
|
||||
# posted to the same expense account.
|
||||
account_currency = get_account_currency(item.expense_account)
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": item.expense_account,
|
||||
"against": _inv_dict["account"],
|
||||
"cost_center": item.cost_center,
|
||||
"debit": 0.0,
|
||||
"credit": credit_amount * -1,
|
||||
"remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name),
|
||||
"debit_in_account_currency": flt(entry.amount),
|
||||
"account_currency": account_currency,
|
||||
"project": item.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user