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

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." + ).format( + frappe.bold(doc.rounding_adjustment), + frappe.bold("Round Off for Opening"), + get_link_to_form("Company", doc.company), + frappe.bold("Disable Rounded Total"), + ) + ) + else: + round_off_account = round_off_for_opening + + gl_entries.append( + self.get_gl_dict( + { + "account": round_off_account, + "against": doc.supplier, + "debit_in_account_currency": doc.rounding_adjustment, + "debit": doc.base_rounding_adjustment, + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else (doc.cost_center or round_off_cost_center), + }, + item=doc, + ) + ) diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py new file mode 100644 index 00000000000..b2050125fe9 --- /dev/null +++ b/erpnext/accounts/services/base_gl_composer.py @@ -0,0 +1,285 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Base class and free functions for per-document GL entry composition. + +``BaseGLComposer`` holds the document being composed and exposes +``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic +lives in the module-level free functions below (``doc`` as first argument), so +``AccountsController`` and ``StockController`` can delegate to them via thin +shims without forcing every GL-building doctype to inherit from those classes. + +Subclasses implement ``compose`` to return the voucher-specific list of GL +entries. +""" + +import frappe +from frappe import _ +from frappe.utils import flt, formatdate + +import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.accounts.services.taxes import set_balance_in_account_currency +from erpnext.accounts.utils import get_account_currency, get_fiscal_years +from erpnext.utilities.regional import temporary_flag + + +def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict: + """Build a GL entry dict populated with doc-level fields.""" + posting_date = args.get("posting_date") or doc.get("posting_date") + fiscal_years = get_fiscal_years(posting_date, company=doc.company) + if len(fiscal_years) > 1: + frappe.throw( + _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format( + formatdate(posting_date) + ) + ) + else: + fiscal_year = fiscal_years[0][0] + + gl_dict = frappe._dict( + { + "company": doc.company, + "posting_date": posting_date, + "fiscal_year": fiscal_year, + "voucher_type": doc.doctype, + "voucher_no": doc.name, + "remarks": doc.get("remarks") or doc.get("remark"), + "debit": 0, + "credit": 0, + "debit_in_account_currency": 0, + "credit_in_account_currency": 0, + "is_opening": doc.get("is_opening") or "No", + "party_type": None, + "party": None, + "project": doc.get("project"), + "post_net_value": args.get("post_net_value"), + "voucher_detail_no": args.get("voucher_detail_no"), + "voucher_subtype": get_voucher_subtype(doc), + } + ) + + with temporary_flag("company", doc.company): + update_gl_dict_with_regional_fields(doc, gl_dict) + + update_gl_dict_with_app_based_fields(doc, gl_dict) + + accounting_dimensions = get_accounting_dimensions() + dimension_dict = frappe._dict() + for dimension in accounting_dimensions: + value = doc.get(dimension) + if item and item.get(dimension): + value = item.get(dimension) + if isinstance(value, list | dict): + continue + dimension_dict[dimension] = value + + gl_dict.update(dimension_dict) + gl_dict.update(args) + + if not account_currency: + account_currency = get_account_currency(gl_dict.account) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + "Purchase Receipt", + "Purchase Invoice", + "Stock Entry", + ]: + validate_account_currency(doc, gl_dict.account, account_currency) + + if gl_dict.account and doc.doctype not in [ + "Journal Entry", + "Period Closing Voucher", + "Payment Entry", + ]: + set_balance_in_account_currency( + gl_dict, + account_currency, + args.get("transaction_exchange_rate") or doc.get("conversion_rate"), + doc.company_currency, + ) + + if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]: + gl_dict.update( + { + "transaction_currency": doc.get("currency") or doc.company_currency, + "transaction_exchange_rate": args.get("transaction_exchange_rate") + or doc.get("conversion_rate", 1), + "debit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "debit" + ), + "credit_in_transaction_currency": get_value_in_transaction_currency( + doc, account_currency, gl_dict, "credit" + ), + } + ) + + if not args.get("against_voucher_type") and doc.get("against_voucher_type"): + gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")}) + + if not args.get("against_voucher") and doc.get("against_voucher"): + gl_dict.update({"against_voucher": doc.get("against_voucher")}) + + return gl_dict + + +def add_gl_entry( + doc, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, + dimensions: dict | None = None, +) -> None: + """Build a GL entry via get_gl_dict and append it to gl_entries. + + `dimensions` sets accounting dimensions explicitly, overriding the values `get_gl_dict` + would otherwise derive from `item` and the parent document. + """ + gl_entry = { + "account": account, + "cost_center": cost_center, + "debit": debit, + "credit": credit, + "against": against_account, + "remarks": remarks, + } + + if project: + gl_entry["project"] = project + + if voucher_detail_no: + gl_entry["voucher_detail_no"] = voucher_detail_no + + if debit_in_account_currency: + gl_entry["debit_in_account_currency"] = debit_in_account_currency + + if credit_in_account_currency: + gl_entry["credit_in_account_currency"] = credit_in_account_currency + + if posting_date: + gl_entry["posting_date"] = posting_date + + if dimensions: + gl_entry.update(dimensions) + + gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item)) + + +def get_voucher_subtype(doc) -> str: + voucher_subtypes = { + "Journal Entry": "voucher_type", + "Payment Entry": "payment_type", + "Stock Entry": "stock_entry_type", + "Asset Capitalization": "entry_type", + } + + for method_name in frappe.get_hooks("voucher_subtypes"): + voucher_subtype = frappe.get_attr(method_name)(doc) + if voucher_subtype: + return voucher_subtype + + if doc.doctype in voucher_subtypes: + return doc.get(voucher_subtypes[doc.doctype]) + elif doc.doctype == "Purchase Receipt" and doc.is_return: + return "Purchase Return" + elif doc.doctype == "Delivery Note" and doc.is_return: + return "Sales Return" + elif doc.doctype == "Sales Invoice" and doc.is_return: + return "Credit Note" + elif doc.doctype == "Sales Invoice" and doc.is_debit_note: + return "Debit Note" + elif doc.doctype == "Purchase Invoice" and doc.is_return: + return "Debit Note" + + return doc.doctype + + +def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float: + if account_currency == doc.get("currency"): + return gl_dict.get(field + "_in_account_currency") + return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1)) + + +def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None: + valid_currency = [doc.company_currency] + if doc.get("currency") and doc.currency != doc.company_currency: + valid_currency.append(doc.currency) + + if account_currency not in valid_currency: + frappe.throw( + _("Account {0} is invalid. Account Currency must be {1}").format( + account, (" " + _("or") + " ").join(valid_currency) + ) + ) + + +@erpnext.allow_regional +def update_gl_dict_with_regional_fields(doc, gl_dict): + pass + + +def update_gl_dict_with_app_based_fields(doc, gl_dict): + for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]): + frappe.get_attr(method)(doc, gl_dict) + + +class BaseGLComposer: + def __init__(self, doc): + self.doc = doc + + def compose(self): + raise NotImplementedError + + def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict: + return get_gl_dict(self.doc, args, account_currency, item) + + def add_gl_entry( + self, + gl_entries: list, + account: str, + cost_center: str, + debit: float, + credit: float, + remarks: str, + against_account: str, + debit_in_account_currency: float | None = None, + credit_in_account_currency: float | None = None, + account_currency: str | None = None, + project: str | None = None, + voucher_detail_no: str | None = None, + item=None, + posting_date=None, + dimensions: dict | None = None, + ) -> None: + add_gl_entry( + self.doc, + gl_entries, + account, + cost_center, + debit, + credit, + remarks, + against_account, + debit_in_account_currency, + credit_in_account_currency, + account_currency, + project, + voucher_detail_no, + item, + posting_date, + dimensions, + ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f5e344a4045..1ca47e5d366 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1489,7 +1489,9 @@ class StockController(AccountsController): voucher_detail_no=None, item=None, posting_date=None, + dimensions=None, ): +<<<<<<< HEAD gl_entry = { "account": account, "cost_center": cost_center, @@ -1497,6 +1499,168 @@ class StockController(AccountsController): "credit": credit, "against": against_account, "remarks": remarks, +======= + from erpnext.accounts.services.base_gl_composer import add_gl_entry + + 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, + ) + + def update_stock_reservation_entries(self): + def get_sre_list(): + table = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(table) + .select(table.name) + .where( + (table.docstatus == 1) + & (table.voucher_type == data_map[purpose or self.doctype]["voucher_type"]) + & ( + table.voucher_no + == data_map[purpose or self.doctype].get( + "voucher_no", item.get("subcontracting_order") + ) + ) + ) + .orderby(table.creation) + ) + if reference_field := data_map[purpose or self.doctype].get("voucher_detail_no_field"): + query = query.where(table.voucher_detail_no == item.get(reference_field)) + else: + query = query.where( + (table.item_code == item.rm_item_code) & (table.warehouse == self.supplier_warehouse) + ) + + return query.run(pluck="name") + + def get_data_map(): + return { + "Subcontracting Delivery": { + "table_name": "items", + "voucher_type": "Subcontracting Inward Order", + "voucher_no": self.get("subcontracting_inward_order"), + "voucher_detail_no_field": "scio_detail", + "field": "delivered_qty", + }, + "Send to Subcontractor": { + "table_name": "items", + "voucher_type": "Subcontracting Order", + "voucher_no": self.get("subcontracting_order"), + "voucher_detail_no_field": "sco_rm_detail", + "field": "transferred_qty", + }, + "Subcontracting Receipt": { + "table_name": "supplied_items", + "voucher_type": "Subcontracting Order", + "field": "consumed_qty", + }, + } + + purpose = self.get("purpose") + if ( + purpose == "Subcontracting Delivery" + or ( + purpose == "Send to Subcontractor" + and frappe.get_value("Subcontracting Order", self.subcontracting_order, "reserve_stock") + ) + or (self.doctype == "Subcontracting Receipt" and self.has_reserved_stock() and not self.is_return) + ): + data_map = get_data_map() + + field = data_map[purpose or self.doctype]["field"] + for item in self.get(data_map[purpose or self.doctype]["table_name"]): + sre_list = get_sre_list() + + if not sre_list: + continue + + qty = item.get("transfer_qty", item.get("consumed_qty")) + for sre in sre_list: + if qty <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + working_qty = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + serial_nos = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in serial_nos: + entry.delivered_qty = 1 if self._action == "submit" else 0 + entry.db_update() + working_qty += 1 + serial_nos.remove(entry.serial_no) + else: + batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in batch_qty: + delivered_qty = min( + (entry.qty - entry.delivered_qty) + if self._action == "submit" + else entry.delivered_qty, + batch_qty[entry.batch_no], + ) + entry.delivered_qty += ( + delivered_qty if self._action == "submit" else (-1 * delivered_qty) + ) + entry.db_update() + working_qty += delivered_qty + batch_qty[entry.batch_no] -= delivered_qty + else: + working_qty = min( + (sre_doc.reserved_qty - sre_doc.get(field)) + if self._action == "submit" + else sre_doc.get(field), + qty, + ) + + sre_doc.set( + field, + sre_doc.get(field) + + (working_qty if self._action == "submit" else (-1 * working_qty)), + ) + sre_doc.db_update() + sre_doc.update_reserved_qty_in_voucher() + sre_doc.update_status() + sre_doc.update_reserved_stock_in_bin() + + qty -= working_qty + + def check_for_on_hold_or_closed_status( + self, ref_doctype: str, ref_fieldname: str, exclude_if_field: str | None = None + ) -> None: + def _include(d): + return d.get(ref_fieldname) and not (exclude_if_field and d.get(exclude_if_field)) + + included = [(d, d.get(ref_fieldname)) for d in self.get("items") if _include(d)] + if not included: + return + + status_map = { + r.name: r.status + for r in frappe.get_all( + ref_doctype, + filters={"name": ["in", {name for _, name in included}]}, + fields=["name", "status"], + ) +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) } if project: diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 56222a452d9..d6d9a8d56cf 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -539,6 +539,7 @@ accounting_dimension_doctypes = [ "Purchase Taxes and Charges", "Shipping Rule", "Landed Cost Item", + "Landed Cost Taxes and Charges", "Asset Value Adjustment", "Asset Repair", "Asset Capitalization", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5535458d3ed..35d3a1dd725 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -441,7 +441,11 @@ execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) 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.rename_ar_ap_ageing_filter erpnext.patches.v15_0.fix_titles diff --git a/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py new file mode 100644 index 00000000000..4fa19fac744 --- /dev/null +++ b/erpnext/patches/v16_0/create_accounting_dimensions_in_landed_cost_taxes_and_charges.py @@ -0,0 +1,11 @@ +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_dimensions, + make_dimension_in_accounting_doctypes, +) + + +def execute(): + dimensions_and_defaults = get_dimensions() + if dimensions_and_defaults: + for dimension in dimensions_and_defaults[0]: + make_dimension_in_accounting_doctypes(dimension, ["Landed Cost Taxes and Charges"]) diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index 4743821d06a..f388f792cc9 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -16,7 +16,16 @@ "has_corrective_cost", "has_operating_cost", "operation_id", +<<<<<<< HEAD "qty" +======= + "qty", + "operating_component", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project" +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) ], "fields": [ { @@ -98,12 +107,44 @@ "no_copy": 1, "non_negative": 1, "read_only": 1 +<<<<<<< HEAD +======= + }, + { + "fieldname": "operating_component", + "fieldtype": "Data", + "hidden": 1, + "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" +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-19 12:21:07.953801", + "modified": "2026-08-04 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py index 60a26b91665..0cb61b15bf1 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/landed_cost_voucher.py @@ -78,6 +78,17 @@ class LandedCostVoucher(Document): self.get_items_from_purchase_receipts() self.set_applicable_charges_on_item() +<<<<<<< HEAD +======= + 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 + for row in self.vendor_invoices: + self.total_vendor_invoices_cost += flt(row.amount) +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) def validate_line_items(self): for d in self.get("items"): @@ -181,6 +192,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")) @@ -379,3 +488,232 @@ def get_pr_items(purchase_receipt): .orderby(pr_item.idx) .run(as_dict=True) ) +<<<<<<< HEAD +======= + + if purchase_receipt.receipt_document_type == "Subcontracting Receipt": + query = query.select( + pr_item.rate.as_("base_rate"), + pr_item.amount.as_("base_amount"), + ) + + elif purchase_receipt.receipt_document_type == "Stock Entry": + query = query.select( + pr_item.basic_rate.as_("base_rate"), + pr_item.basic_amount.as_("base_amount"), + ) + + query = query.where(pr_item.is_finished_item == 1) + else: + query = query.select( + pr_item.base_rate, + pr_item.base_amount, + pr_item.is_fixed_asset, + ) + + return query.run(as_dict=True) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_vendor_invoices( + doctype: str, txt: str | None, searchfield: Any, start: int, page_len: int, filters: dict +): + if not frappe.has_permission("Purchase Invoice", "read"): + return [] + + if txt and txt.lower().startswith(("select", "delete", "update")): + frappe.throw(_("Invalid search query"), title=_("Invalid Query")) + + query = get_vendor_invoice_query(filters) + + if txt: + query = query.where(frappe.qb.DocType(doctype).name.like(f"%{txt}%")) + + if start: + query = query.limit(page_len).offset(start) + + return query.run(as_list=True) + + +def get_vendor_invoice_query(filters): + doctype = frappe.qb.DocType("Purchase Invoice") + child_doctype = frappe.qb.DocType("Purchase Invoice Item") + item = frappe.qb.DocType("Item") + + query = ( + frappe.qb.from_(doctype) + .inner_join(child_doctype) + .on(child_doctype.parent == doctype.name) + .inner_join(item) + .on(item.name == child_doctype.item_code) + .select( + doctype.name, + (doctype.base_total - doctype.claimed_landed_cost_amount).as_("unclaimed_amount"), + ) + .where( + (doctype.docstatus == 1) + & (doctype.is_subcontracted == 0) + & (doctype.is_return == 0) + & (doctype.update_stock == 0) + & (doctype.company == filters.get("company")) + & (item.is_stock_item == 0) + # WHERE not HAVING: no GROUP BY here, and Postgres rejects HAVING on a SELECT alias + & ((doctype.base_total - doctype.claimed_landed_cost_amount) > 0) + ) + ) + + if filters.get("name"): + query = query.where(doctype.name == filters.get("name")) + + return query + + +def set_landed_cost_voucher_amount(doc): + """Set landed_cost_voucher_amount on the receipt document's items from submitted LCVs.""" + for d in doc.get("items"): + lcv_item = frappe.qb.DocType("Landed Cost Item") + query = ( + frappe.qb.from_(lcv_item) + .select(Sum(lcv_item.applicable_charges), Max(lcv_item.cost_center)) + .where((lcv_item.docstatus == 1) & (lcv_item.receipt_document == doc.name)) + ) + + if doc.doctype == "Stock Entry": + query = query.where(lcv_item.stock_entry_item == d.name) + else: + query = query.where(lcv_item.purchase_receipt_item == d.name) + + lc_voucher_data = query.run(as_list=True) + + d.landed_cost_voucher_amount = lc_voucher_data[0][0] if lc_voucher_data else 0.0 + if not d.cost_center and lc_voucher_data and lc_voucher_data[0][1]: + d.db_set("cost_center", lc_voucher_data[0][1]) + + +def has_landed_cost_amount(doc): + for row in doc.items: + if row.get("landed_cost_voucher_amount"): + return True + + return False + + +def get_lcv_dimension_fields(): + """Every field whose value should travel from an LCV row onto the landed cost GL entry. + + `get_accounting_dimensions()` covers custom dimensions only, so cost center and project + are prepended explicitly. + """ + from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( + get_accounting_dimensions, + ) + + return ["cost_center", "project", *get_accounting_dimensions()] + + +def get_row_dimensions(tax_row, lcv_item, dimension_fields): + """Resolve the dimensions of a landed cost charge: tax row first, then the LCV item row. + + Blanks are left blank on purpose - the GL composers fall back to the receipt item and + then the receipt document from there. + """ + return frappe._dict( + {field: (tax_row.get(field) or lcv_item.get(field) or None) for field in dimension_fields} + ) + + +def get_custom_dimension_overrides(entry): + """Custom dimension overrides for a landed cost GL entry. + + Cost center and project are excluded because the composers pass them as explicit + arguments. Only truthy values are returned: `get_gl_dict` applies `args` last, so a + `None` here would wipe out the receipt item fallback instead of deferring to it. + """ + return { + dimension: value + for dimension, value in (entry.dimensions or {}).items() + if value and dimension not in ("cost_center", "project") + } + + +def get_item_account_wise_lcv_entries(doc): + """Landed cost charges for a receipt document, consumed by the GL composers. + + Returns `{(item_code, receipt_row_name): [entry, ...]}` where each entry is a + `frappe._dict(expense_account, amount, base_amount, dimensions)`. + + Charges are grouped by *(expense account, dimension values)* rather than by expense + account alone, so two tax rows - whether in one voucher or across vouchers - that post + to the same account with different dimensions stay separate GL entries instead of + silently collapsing into the first row's dimensions. + """ + if not has_landed_cost_amount(doc): + return + + landed_cost_vouchers = frappe.get_all( + "Landed Cost Purchase Receipt", + fields=["parent"], + filters={"receipt_document": doc.name, "docstatus": 1}, + ) + + if not landed_cost_vouchers: + return + + item_account_wise_cost = {} + dimension_fields = get_lcv_dimension_fields() + + row_fieldname = "purchase_receipt_item" + if doc.doctype == "Stock Entry": + row_fieldname = "stock_entry_item" + + for lcv in landed_cost_vouchers: + landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent) + + based_on_field = "applicable_charges" + # Use amount field for total item cost for manually cost distributed LCVs + if landed_cost_voucher_doc.distribute_charges_based_on != "Distribute Manually": + based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on) + + total_item_cost = 0 + + if based_on_field: + for item in landed_cost_voucher_doc.items: + total_item_cost += item.get(based_on_field) + + for item in landed_cost_voucher_doc.items: + if item.receipt_document == doc.name: + charges = item_account_wise_cost.setdefault((item.item_code, item.get(row_fieldname)), {}) + + for account in landed_cost_voucher_doc.taxes: + exchange_rate = account.exchange_rate or 1 + dimensions = get_row_dimensions(account, item, dimension_fields) + group_key = ( + account.expense_account, + tuple(dimensions.get(field) for field in dimension_fields), + ) + + item_row = charges.get(group_key) + if item_row is None: + item_row = charges[group_key] = frappe._dict( + expense_account=account.expense_account, + amount=0.0, + base_amount=0.0, + dimensions=dimensions, + ) + + if total_item_cost > 0: + item_row.amount += account.amount * item.get(based_on_field) / total_item_cost + + item_row.base_amount += ( + account.base_amount * item.get(based_on_field) / total_item_cost + ) + else: + # Pre-existing behaviour: this adds the item's full applicable charges once + # per tax row. Unreachable for submitted vouchers, since + # validate_applicable_charges_for_item rejects a zero total. + item_row.amount += item.applicable_charges / exchange_rate + item_row.base_amount += item.applicable_charges + + return {key: list(charges.values()) for key, charges in item_account_wise_cost.items()} +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py index 3412d818e31..479b91721ce 100644 --- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py +++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py @@ -1224,4 +1224,291 @@ def distribute_landed_cost_on_items(lcv): item.applicable_charges = flt(item.applicable_charges, lcv.precision("applicable_charges", item)) +<<<<<<< HEAD test_records = frappe.get_test_records("Landed Cost Voucher") +======= +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") +>>>>>>> 918e5a2 (fix(stock): carry accounting dimensions from Landed Cost Voucher char… (#56981)) diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py new file mode 100644 index 00000000000..55dead0d69d --- /dev/null +++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py @@ -0,0 +1,457 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import cint, flt + +import erpnext +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock import get_warehouse_account +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class PurchaseReceiptGLComposer(BaseStockGLComposer): + """GL composer for Purchase Receipt. + + Builds GL entries for stock/asset inward, taxes, purchase expense, and + regional adjustments. Does not delegate to the base stock GL loop — + PR has its own per-item logic (provisional accounting, fixed assets, LCV, + sub-contracting, divisional loss). + """ + + def compose( + self, + inventory_account_map: dict | None = None, + via_landed_cost_voucher: bool = False, + ) -> list: + gl_entries = [] + self._make_item_gl_entries(gl_entries, inventory_account_map) + self._make_tax_gl_entries(gl_entries, via_landed_cost_voucher) + self.doc.set_gl_entry_for_purchase_expense(gl_entries) + + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_regional_gl_entries + + update_regional_gl_entries(gl_entries, self.doc) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None: + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + get_purchase_document_details, + ) + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_stock_value_difference + + doc = self.doc + provisional_accounting_for_non_stock_items = cint( + frappe.db.get_value("Company", doc.company, "enable_provisional_accounting_for_non_stock_items") + ) + + exchange_rate_map, net_rate_map = get_purchase_document_details(doc) + stock_items = doc.get_stock_items() + warehouse_with_no_account = [] + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + + def validate_account(account_type): + frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type)) + + def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name): + account_currency = get_account_currency(stock_asset_account_name) + if not stock_asset_account_name: + validate_account("Asset or warehouse account") + self.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_account_name, + cost_center=d.cost_center, + debit=stock_value_diff, + credit=0.0, + remarks=remarks, + against_account=stock_asset_rbnb, + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_stock_received_but_not_billed_entry(item): + if ( + doc.get("is_return") + and item.return_qty_from_rejected_warehouse + and not frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ) + ): + return 0.0 + + account = stock_asset_rbnb + if item.from_warehouse: + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "from_warehouse") + account = _inv_dict["account"] + + account_currency = get_account_currency(account) + + credit_amount = ( + flt(item.base_net_amount, item.precision("base_net_amount")) + if account_currency == doc.company_currency + else flt(item.net_amount, item.precision("net_amount")) + ) + + outgoing_amount = item.base_net_amount + if doc.is_internal_transfer() and item.valuation_rate: + outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse)) + credit_amount = outgoing_amount + + if item.get("rejected_qty") and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse) + credit_amount = outgoing_amount + + if credit_amount: + if not account: + validate_account("Stock or Asset Received But Not Billed") + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + account_currency=account_currency, + project=item.project, + item=item, + ) + + if d.get("purchase_invoice"): + if ( + exchange_rate_map[item.purchase_invoice] + and doc.conversion_rate != exchange_rate_map[item.purchase_invoice] + and item.net_rate == net_rate_map[item.purchase_invoice_item] + ): + discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * ( + exchange_rate_map[item.purchase_invoice] - doc.conversion_rate + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=discrepancy_caused_by_exchange_rate_difference, + remarks=remarks, + against_account=doc.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + project=item.project, + item=item, + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=doc.get_company_default("exchange_gain_loss_account"), + cost_center=d.cost_center, + debit=discrepancy_caused_by_exchange_rate_difference, + credit=0.0, + remarks=remarks, + against_account=doc.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + project=item.project, + item=item, + ) + + return outgoing_amount + + def make_landed_cost_gl_entries(item): + if not (item.landed_cost_voucher_amount and landed_cost_entries): + return + + for entry in landed_cost_entries.get((item.item_code, item.name), []): + if not (entry.amount or entry.base_amount): + continue + + account = entry.expense_account + if not account: + validate_account("Landed Cost Account") + + account_currency = get_account_currency(account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=entry.dimensions.cost_center or item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=stock_asset_account_name, + credit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=entry.dimensions.project or item.project, + item=item, + dimensions=get_custom_dimension_overrides(entry), + ) + + def make_expenses_added_to_stock_entries(item): + if not self.book_stock_expense_enabled(): + return + + amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount")) + if amount and not item.is_fixed_asset: + self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item) + + def make_amount_difference_entry(item): + if item.amount_difference_with_purchase_invoice and stock_asset_rbnb: + account_currency = get_account_currency(stock_asset_rbnb) + self.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_rbnb, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.amount_difference_with_purchase_invoice), + remarks=_("Adjustment based on Purchase Invoice rate"), + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_sub_contracting_gl_entries(item): + if flt(item.rm_supp_cost) and supplier_warehouse_account: + self.add_gl_entry( + gl_entries=gl_entries, + account=supplier_warehouse_account, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.rm_supp_cost), + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=supplier_warehouse_account_currency, + project=item.project, + item=item, + ) + + def make_divisional_loss_gl_entry(item, outgoing_amount): + if item.is_fixed_asset: + return + + valuation_amount_as_per_doc = ( + flt(outgoing_amount, d.precision("base_net_amount")) + + flt(item.landed_cost_voucher_amount) + + flt(item.rm_supp_cost) + + flt(item.item_tax_amount) + + flt(item.amount_difference_with_purchase_invoice) + ) + + divisional_loss = flt( + valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount") + ) + + if item.get("rejected_qty") and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse) + divisional_loss -= rejected_item_cost + + if divisional_loss: + loss_account = self.get_divisional_loss_account(item, stock_asset_rbnb) + + cost_center = item.cost_center or frappe.get_cached_value( + "Company", doc.company, "cost_center" + ) + account_currency = get_account_currency(loss_account) + self.add_gl_entry( + gl_entries=gl_entries, + account=loss_account, + cost_center=cost_center, + debit=divisional_loss, + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + for d in doc.get("items"): + remarks = doc.get("remarks") or _("Accounting Entry for {0}").format( + "Asset" if d.is_fixed_asset else "Stock" + ) + + if ( + provisional_accounting_for_non_stock_items + and d.item_code not in stock_items + and flt(d.qty) + and d.get("provisional_expense_account") + and not d.is_fixed_asset + ): + doc.add_provisional_gl_entry( + d, gl_entries, doc.posting_date, d.get("provisional_expense_account") + ) + elif flt(d.qty) and (flt(d.valuation_rate) or doc.is_return): + if not ( + (erpnext.is_perpetual_inventory_enabled(doc.company) and d.item_code in stock_items) + or (d.is_fixed_asset and not d.purchase_invoice) + ): + continue + + stock_asset_rbnb = ( + doc.get_company_default("asset_received_but_not_billed") + if d.is_fixed_asset + else doc.get_company_default("stock_received_but_not_billed") + ) + if d.is_fixed_asset: + stock_asset_account_name = d.expense_account + stock_value_diff = ( + flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount) + ) + elif inventory_account := doc.get_inventory_account_dict(d, inventory_account_map): + stock_value_diff = get_stock_value_difference(doc.name, d.name, d.warehouse) + stock_asset_account_name = inventory_account["account"] + + supplier_warehouse_account = None + supplier_warehouse_account_currency = None + if doc.supplier_warehouse: + supplier_warehouse_account = get_warehouse_account( + frappe.get_cached_doc("Warehouse", doc.supplier_warehouse), + raise_error=bool(flt(d.rm_supp_cost)), + ) + if supplier_warehouse_account: + supplier_warehouse_account_currency = get_account_currency( + supplier_warehouse_account + ) + + if ( + flt(stock_value_diff) == flt(d.rm_supp_cost) + and supplier_warehouse_account + and stock_asset_account_name == supplier_warehouse_account + ): + continue + + if (flt(d.valuation_rate) or doc.is_return or d.is_fixed_asset) and flt(d.qty): + make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) + outgoing_amount = make_stock_received_but_not_billed_entry(d) + make_landed_cost_gl_entries(d) + make_expenses_added_to_stock_entries(d) + make_amount_difference_entry(d) + make_sub_contracting_gl_entries(d) + make_divisional_loss_gl_entry(d, outgoing_amount) + elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or ( + not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials") + and d.rejected_warehouse + and d.rejected_warehouse not in warehouse_with_no_account + ): + warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse) + + if d.is_fixed_asset and d.landed_cost_voucher_amount: + doc.update_assets(d, d.valuation_rate) + + if d.rejected_qty and frappe.db.get_single_value( + "Buying Settings", "set_valuation_rate_for_rejected_materials" + ): + stock_asset_rbnb = ( + doc.get_company_default("asset_received_but_not_billed") + if d.is_fixed_asset + else doc.get_company_default("stock_received_but_not_billed") + ) + + stock_value_diff = get_stock_value_difference(doc.name, d.name, d.rejected_warehouse) + _inv_dict = doc.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse") + stock_asset_account_name = _inv_dict["account"] + + make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) + if not d.qty: + make_stock_received_but_not_billed_entry(d) + + if warehouse_with_no_account: + frappe.msgprint( + _("No accounting entries for the following warehouses") + + ": \n" + + "\n".join(warehouse_with_no_account) + ) + + def get_divisional_loss_account(self, item, stock_asset_rbnb): + """Account that absorbs the difference between the document value and the value actually + booked into stock. For a Standard Cost item this difference is a purchase price variance + (receipt rate vs standard rate), so it goes to the Purchase Price Variance account; for all + other items it keeps the existing behaviour (default expense account, or the item's expense + account on a return).""" + from erpnext.stock.utils import get_valuation_method + + doc = self.doc + if item.item_code and get_valuation_method(item.item_code, doc.company) == "Standard Cost": + from erpnext.stock.doctype.item_standard_cost.item_standard_cost import ( + get_purchase_price_variance_account, + ) + + return get_purchase_price_variance_account(item.item_code, doc.company) + + loss_account = ( + doc.get_company_default("default_expense_account", ignore_validation=True) or stock_asset_rbnb + ) + if doc.is_return and item.expense_account: + loss_account = item.expense_account + + return loss_account + + def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None: + doc = self.doc + negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")]) + + # Amount of each valuation charge actually capitalized into stock/asset valuation, keyed by + # tax row name. This is what must be credited to each tax account - a non-stock item's share + # of a spread-across-all-items charge is not capitalized, so it is excluded here. + capitalized_valuation_tax = doc.get_capitalized_valuation_tax() + + valuation_tax = {} + for tax in doc.get("taxes"): + if tax.category in ("Valuation", "Valuation and Total") and flt( + tax.base_tax_amount_after_discount_amount + ): + if not tax.cost_center: + frappe.throw( + _("Cost Center is required in row {0} in Taxes table for type {1}").format( + tax.idx, _(tax.category) + ) + ) + + valuation_tax[tax.name] = capitalized_valuation_tax.get(tax.name, 0.0) + + if negative_expense_to_be_booked and valuation_tax: + against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0]) + total_valuation_amount = sum(valuation_tax.values()) + amount_including_divisional_loss = negative_expense_to_be_booked + i = 1 + for tax in doc.get("taxes"): + if valuation_tax.get(tax.name): + account = tax.account_head + if i == len(valuation_tax): + applicable_amount = amount_including_divisional_loss + else: + applicable_amount = negative_expense_to_be_booked * ( + valuation_tax[tax.name] / total_valuation_amount + ) + amount_including_divisional_loss -= applicable_amount + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=tax.cost_center, + debit=0.0, + credit=applicable_amount, + remarks=doc.remarks or _("Accounting Entry for Stock"), + against_account=against_accounts, + item=tax, + ) + + i += 1 diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py new file mode 100644 index 00000000000..6b20416b3b5 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py @@ -0,0 +1,336 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class StockEntryGLComposer(BaseStockGLComposer): + """GL composer for Stock Entry. + + Extends the base stock GL loop with additional-cost entries (from the + ``additional_costs`` child table) and landed-cost voucher adjustments. + The difference is posted to warehouse/balance-sheet accounts, so P&L + enforcement on the expense account is off. + """ + + enforce_pl_expense_account = False + book_expenses_added_to_stock = True + + def compose(self, inventory_account_map: dict | None = None) -> list: + doc = self.doc + gl_entries = super().compose(inventory_account_map) + + if doc.purpose in ("Repack", "Manufacture"): + total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item) + else: + total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse) + + divide_based_on = total_basic_amount + if doc.get("additional_costs") and not total_basic_amount: + divide_based_on = sum(item.qty for item in doc.get("items")) + + item_account_wise_additional_cost = self._build_additional_cost_per_item_account( + total_basic_amount, divide_based_on + ) + if item_account_wise_additional_cost: + self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost) + + self._append_lcv_gl_entries(gl_entries, inventory_account_map) + + if doc.purpose in ("Repack", "Manufacture"): + self._append_manufacturing_variance_gl_entries(gl_entries, inventory_account_map) + elif doc.purpose == "Material Receipt": + self._append_receipt_variance_gl_entries(gl_entries) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _append_manufacturing_variance_gl_entries( + self, gl_entries: list, inventory_account_map: dict + ) -> None: + """For Standard Cost finished goods produced via Manufacture/Repack, stock is booked at the item's + standard rate, while the entry consumes raw-material (plus additional/landed) cost. The difference + is a manufacturing variance and is reclassified from the finished good's expense account to the + Manufacturing Variance account (mirrors Purchase Price Variance on a Purchase Receipt).""" + precision = self.get_debit_field_precision() + # Reuse the SLE map the base composer already fetched in compose() to avoid a second identical query. + sle_map = self._sle_map + + from erpnext.stock.doctype.item_standard_cost.item_standard_cost import ( + get_manufacturing_variance_account, + ) + + for d in self.doc.get("items"): + variance = self._get_finished_good_variance(d, sle_map, precision) + if variance: + account = get_manufacturing_variance_account(d.item_code, self.doc.company) + remarks = self.doc.get("remarks") or _("Manufacturing Variance for {0}").format(d.item_code) + self._append_standard_cost_variance_pair( + gl_entries, d, variance, account, remarks, inventory_account_map + ) + + def _append_receipt_variance_gl_entries(self, gl_entries: list) -> None: + """For a Standard Cost item received via Material Receipt, stock is booked at the item's standard + rate while the row may carry a manually-set basic rate plus additional/landed cost. The gap + between that intended cost and the standard value is a purchase price variance, reclassified from + the item's expense account to the Purchase Price Variance account.""" + from erpnext.stock.doctype.item_standard_cost.item_standard_cost import ( + get_purchase_price_variance_account, + ) + + precision = self.get_debit_field_precision() + sle_map = self._sle_map + + for d in self.doc.get("items"): + variance = self._get_receipt_variance(d, sle_map, precision) + if variance: + account = get_purchase_price_variance_account(d.item_code, self.doc.company) + remarks = self.doc.get("remarks") or _("Purchase Price Variance for {0}").format(d.item_code) + self._append_standard_cost_variance_pair(gl_entries, d, variance, account, remarks) + + def _get_receipt_variance(self, item, sle_map, precision) -> float: + """Purchase price variance for a Standard Cost item on a Material Receipt: the gap between the full + computed incoming cost (basic amount + additional cost + LCV, i.e. ``amount``) and the standard + value booked into stock. 0 for anything that is not a plain Standard Cost receipt row.""" + from erpnext.stock.utils import get_valuation_method + + if not item.t_warehouse or item.s_warehouse: + return 0.0 + + if ( + item.get("is_finished_item") + or item.get("secondary_item_type") + or item.get("is_legacy_scrap_item") + ): + return 0.0 + + if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost": + return 0.0 + + standard_value = sum( + flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0 + ) + + return flt(flt(item.amount) - standard_value, precision) + + def _get_finished_good_variance(self, item, sle_map, precision) -> float: + """Manufacturing variance for a Standard Cost finished good: the gap between the full computed + incoming cost (raw-material share + additional cost + LCV, i.e. ``amount``) and the standard value + actually booked into stock. Positive = consumed more than standard (unfavorable). 0 for anything + that is not a Standard Cost finished good.""" + from erpnext.stock.utils import get_valuation_method + + if not item.is_finished_item or not item.t_warehouse: + return 0.0 + + if get_valuation_method(item.item_code, self.doc.company) != "Standard Cost": + return 0.0 + + # Value actually booked into stock for this finished good = qty * standard rate. + standard_value = sum( + flt(sle.stock_value_difference) for sle in sle_map.get(item.name, []) if flt(sle.actual_qty) > 0 + ) + + return flt(flt(item.amount) - standard_value, precision) + + def _append_standard_cost_variance_pair( + self, + gl_entries: list, + item, + variance: float, + variance_account: str, + remarks: str, + inventory_account_map: dict | None = None, + ) -> None: + """Reclassify ``variance`` from the item's expense account to the given variance account, + restoring the expense account to the value it would carry without Standard Cost.""" + doc = self.doc + cost_center = item.cost_center or frappe.get_cached_value("Company", doc.company, "cost_center") + project = item.project or doc.get("project") + + inventory_account = None + if inventory_account_map: + inventory_account = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")[ + "account" + ] + + gl_entries.append( + self.get_gl_dict( + { + "account": variance_account, + "against": item.expense_account, + "cost_center": cost_center, + "remarks": remarks, + "debit": variance, + "project": project, + }, + item=item, + ) + ) + gl_entries.append( + self.get_gl_dict( + { + "account": item.expense_account, + "against": inventory_account or variance_account, + "cost_center": cost_center, + "remarks": remarks, + "debit": -1 * variance, + "project": project, + }, + item=item, + ) + ) + + def _build_additional_cost_per_item_account( + self, total_basic_amount: float, divide_based_on: float + ) -> dict: + doc = self.doc + item_account_wise_additional_cost = {} + + for t in doc.get("additional_costs"): + for d in doc.get("items"): + if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item: + continue + elif not d.t_warehouse: + continue + + item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) + item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( + t.expense_account, {"amount": 0.0, "base_amount": 0.0} + ) + + multiply_based_on = d.basic_amount if total_basic_amount else d.qty + entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] + entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on + entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on + + return item_account_wise_additional_cost + + def get_valuation_method(self, item_code: str) -> str: + from erpnext.stock.utils import get_valuation_method + + return get_valuation_method(item_code, self.doc.company) + + def _append_additional_cost_gl_entries( + self, gl_entries: list, item_account_wise_additional_cost: dict + ) -> None: + doc = self.doc + precision = self.get_debit_field_precision() + + for d in doc.get("items"): + for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items(): + if not amount: + continue + + amount["amount"] = flt(amount["amount"], precision) + amount["base_amount"] = flt(amount["base_amount"], precision) + + gl_entries.append( + self.get_gl_dict( + { + "account": account, + "against": d.expense_account, + "cost_center": d.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit_in_account_currency": flt(amount["amount"]), + "credit": flt(amount["base_amount"]), + }, + item=d, + ) + ) + + if self.get_valuation_method(d.item_code) == "Standard Cost": + gl_entries.append( + self.get_gl_dict( + { + "account": d.expense_account, + "against": account, + "cost_center": d.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "debit": flt(amount["base_amount"]), + }, + item=d, + ) + ) + + else: + gl_entries.append( + self.get_gl_dict( + { + "account": d.expense_account, + "against": account, + "cost_center": d.cost_center, + "remarks": doc.get("remarks") or _("Accounting Entry for Stock"), + "credit": -1 * flt(amount["base_amount"]), + }, + item=d, + ) + ) + + def _append_lcv_gl_entries(self, gl_entries: list, inventory_account_map: dict) -> None: + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) + + doc = self.doc + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + if not landed_cost_entries: + return + + for item in doc.get("items"): + if item.s_warehouse: + continue + + for entry in landed_cost_entries.get((item.item_code, item.name), []): + if not (entry.amount or entry.base_amount): + continue + + account_currency = get_account_currency(entry.expense_account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) + + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse") + gl_dict = self.get_gl_dict( + { + "account": entry.expense_account, + "against": _inv_dict["account"], + "cost_center": entry.dimensions.cost_center or item.cost_center, + "debit": 0.0, + "credit": credit_amount, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "credit_in_account_currency": flt(entry.amount), + "account_currency": account_currency, + "project": entry.dimensions.project or item.project, + }, + item=item, + ) + gl_dict.update(get_custom_dimension_overrides(entry)) + gl_entries.append(gl_dict) + + # Reclass leg: keeps the item's dimensions so it nets against the base item entry + # posted to the same expense account. + account_currency = get_account_currency(item.expense_account) + gl_entries.append( + self.get_gl_dict( + { + "account": item.expense_account, + "against": _inv_dict["account"], + "cost_center": item.cost_center, + "debit": 0.0, + "credit": credit_amount * -1, + "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name), + "debit_in_account_currency": flt(entry.amount), + "account_currency": account_currency, + "project": item.project, + }, + item=item, + ) + ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py new file mode 100644 index 00000000000..e1217edb81e --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py @@ -0,0 +1,276 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.accounts.general_ledger import process_gl_map +from erpnext.accounts.utils import get_account_currency +from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer + + +class SubcontractingReceiptGLComposer(BaseStockGLComposer): + """GL composer for Subcontracting Receipt. + + Builds GL entries for accepted stock, service cost, supplier warehouse + (raw materials), additional costs, LCV, and divisional loss. + """ + + def compose(self, inventory_account_map: dict | None = None) -> list: + import erpnext + + doc = self.doc + if not erpnext.is_perpetual_inventory_enabled(doc.company): + return [] + + gl_entries = [] + self._make_item_gl_entries(gl_entries, inventory_account_map) + self._make_item_gl_entries_for_lcv(gl_entries, inventory_account_map) + + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None: + doc = self.doc + warehouse_with_no_account = [] + + supplied_items_details = frappe._dict() + for item in doc.supplied_items: + supplied_items_details.setdefault(item.reference_name, []).append( + frappe._dict( + { + "item_code": item.rm_item_code, + "amount": item.amount, + "expense_account": item.expense_account, + "cost_center": item.cost_center, + } + ) + ) + + for item in doc.items: + if flt(item.rate) and flt(item.qty): + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + if _inv_dict.get("account"): + stock_value_diff = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Subcontracting Receipt", + "voucher_no": doc.name, + "voucher_detail_no": item.name, + "warehouse": item.warehouse, + "is_cancelled": 0, + }, + "stock_value_difference", + ) + + remarks = doc.get("remarks") or _("Accounting Entry for Stock") + + self.add_gl_entry( + gl_entries=gl_entries, + account=_inv_dict["account"], + cost_center=item.cost_center, + debit=stock_value_diff, + credit=0.0, + remarks=remarks, + against_account=item.expense_account, + account_currency=_inv_dict["account_currency"], + project=item.project, + item=item, + ) + + service_cost = flt( + item.service_cost_per_qty, item.precision("service_cost_per_qty") + ) * flt(item.qty, item.precision("qty")) + + self.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=flt(stock_value_diff) - service_cost, + remarks=remarks, + against_account=_inv_dict["account"], + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + + service_account = item.service_expense_account or item.expense_account + self.add_gl_entry( + gl_entries=gl_entries, + account=service_account, + cost_center=item.cost_center, + debit=0.0, + credit=service_cost, + remarks=remarks, + against_account=_inv_dict["account"], + account_currency=get_account_currency(service_account), + project=item.project, + item=item, + ) + + if flt(item.rm_supp_cost): + for rm_item in supplied_items_details.get(item.name): + _inv_dict = doc.get_inventory_account_dict( + rm_item, inventory_account_map, "supplier_warehouse" + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=_inv_dict.get("account"), + cost_center=rm_item.cost_center or item.cost_center, + debit=0.0, + credit=flt(rm_item.amount), + remarks=remarks, + against_account=rm_item.expense_account or item.expense_account, + account_currency=_inv_dict.get("account_currency"), + project=item.project, + item=item, + ) + self.add_gl_entry( + gl_entries=gl_entries, + account=rm_item.expense_account or item.expense_account, + cost_center=rm_item.cost_center or item.cost_center, + debit=flt(rm_item.amount), + credit=0.0, + remarks=remarks, + against_account=_inv_dict.get("account"), + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + + if item.additional_cost_per_qty: + self.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=doc.cost_center or doc.get_company_default("cost_center"), + debit=item.qty * item.additional_cost_per_qty, + credit=0.0, + remarks=remarks, + against_account=None, + account_currency=get_account_currency(item.expense_account), + ) + + if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")): + loss_account = doc.get_company_default( + "stock_adjustment_account", ignore_validation=True + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=loss_account, + cost_center=item.cost_center, + debit=0.0, + credit=divisional_loss, + remarks=remarks, + against_account=item.expense_account, + account_currency=get_account_currency(loss_account), + project=item.project, + item=item, + ) + self.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=divisional_loss, + credit=0.0, + remarks=remarks, + against_account=loss_account, + account_currency=get_account_currency(item.expense_account), + project=item.project, + item=item, + ) + elif ( + item.warehouse not in warehouse_with_no_account + or item.rejected_warehouse not in warehouse_with_no_account + ): + warehouse_with_no_account.append(item.warehouse) + + for row in doc.additional_costs: + credit_amount = ( + flt(row.base_amount) + if (row.base_amount or row.account_currency != doc.company_currency) + else flt(row.amount) + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=row.expense_account, + cost_center=doc.cost_center or doc.get_company_default("cost_center"), + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=None, + account_currency=get_account_currency(row.expense_account), + ) + + if warehouse_with_no_account: + frappe.msgprint( + _("No accounting entries for the following warehouses") + + ": \n" + + "\n".join(warehouse_with_no_account) + ) + + def _make_item_gl_entries_for_lcv(self, gl_entries: list, inventory_account_map: dict | None) -> None: + from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( + get_custom_dimension_overrides, + ) + + doc = self.doc + landed_cost_entries = doc.get_item_account_wise_lcv_entries() + + if not landed_cost_entries: + return + + for item in doc.items: + item_entries = landed_cost_entries.get((item.item_code, item.name), []) + + if item.landed_cost_voucher_amount and item_entries: + remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(doc.name) + _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map) + + for entry in item_entries: + if not (entry.amount or entry.base_amount): + continue + + account_currency = get_account_currency(entry.expense_account) + credit_amount = ( + flt(entry.base_amount) + if (entry.base_amount or account_currency != doc.company_currency) + else flt(entry.amount) + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=entry.expense_account, + cost_center=entry.dimensions.cost_center or item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=_inv_dict["account"], + credit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=entry.dimensions.project or item.project, + item=item, + dimensions=get_custom_dimension_overrides(entry), + ) + + # Reclass leg: keeps the item's dimensions so it nets against the base item + # entry posted to the same expense account. + account_currency = get_account_currency(item.expense_account) + + self.add_gl_entry( + gl_entries=gl_entries, + account=item.expense_account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount * -1, + remarks=remarks, + against_account=_inv_dict["account"], + debit_in_account_currency=flt(entry.amount), + account_currency=account_currency, + project=item.project, + item=item, + )