diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 9d3c9eb501e..f1e9e261309 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -33,6 +33,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched get_depr_schedule, ) from erpnext.controllers.accounts_controller import AccountsController +from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate class StockAccountInvalidTransaction(frappe.ValidationError): @@ -273,93 +274,7 @@ class JournalEntry(AccountsController): ) def apply_tax_withholding(self): - from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map - - if not self.apply_tds or self.voucher_type not in ("Debit Note", "Credit Note"): - return - - parties = [d.party for d in self.get("accounts") if d.party] - parties = list(set(parties)) - - if len(parties) > 1: - frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) - - account_type_map = get_account_type_map(self.company) - party_type = "supplier" if self.voucher_type == "Credit Note" else "customer" - doctype = "Purchase Invoice" if self.voucher_type == "Credit Note" else "Sales Invoice" - debit_or_credit = ( - "debit_in_account_currency" - if self.voucher_type == "Credit Note" - else "credit_in_account_currency" - ) - rev_debit_or_credit = ( - "credit_in_account_currency" - if debit_or_credit == "debit_in_account_currency" - else "debit_in_account_currency" - ) - - party_account = get_party_account(party_type.title(), parties[0], self.company) - - net_total = sum( - d.get(debit_or_credit) - for d in self.get("accounts") - if account_type_map.get(d.account) not in ("Tax", "Chargeable") - ) - - party_amount = sum( - d.get(rev_debit_or_credit) for d in self.get("accounts") if d.account == party_account - ) - - inv = frappe._dict( - { - party_type: parties[0], - "doctype": doctype, - "company": self.company, - "posting_date": self.posting_date, - "net_total": net_total, - } - ) - - tax_withholding_details, advance_taxes, voucher_wise_amount = get_party_tax_withholding_details( - inv, self.tax_withholding_category - ) - - if not tax_withholding_details: - return - - accounts = [] - for d in self.get("accounts"): - if d.get("account") == tax_withholding_details.get("account_head"): - d.update( - { - "account": tax_withholding_details.get("account_head"), - debit_or_credit: tax_withholding_details.get("tax_amount"), - } - ) - - accounts.append(d.get("account")) - - if d.get("account") == party_account: - d.update({rev_debit_or_credit: party_amount - tax_withholding_details.get("tax_amount")}) - - if not accounts or tax_withholding_details.get("account_head") not in accounts: - self.append( - "accounts", - { - "account": tax_withholding_details.get("account_head"), - rev_debit_or_credit: tax_withholding_details.get("tax_amount"), - "against_account": parties[0], - }, - ) - - to_remove = [ - d - for d in self.get("accounts") - if not d.get(rev_debit_or_credit) and d.account == tax_withholding_details.get("account_head") - ] - - for d in to_remove: - self.remove(d) + JournalEntryTaxWithholding(self).apply() def update_asset_value(self): if self.flags.planned_depr_entry or self.voucher_type != "Depreciation Entry": @@ -1281,6 +1196,230 @@ class JournalEntry(AccountsController): frappe.throw(_("Accounts table cannot be blank.")) +class JournalEntryTaxWithholding: + def __init__(self, journal_entry): + self.doc: JournalEntry = journal_entry + self.party = None + self.party_type = None + self.party_account = None + self.party_row = None + self.existing_tds_rows = [] + self.precision = None + self.has_multiple_parties = False + + # Direction fields based on party type + self.party_field = None # "credit" for Supplier, "debit" for Customer + self.reverse_field = None # opposite of party_field + + def apply(self): + if not self._set_party_info(): + return + + self._setup_direction_fields() + self._reset_existing_tds() + + if not self._should_apply_tds(): + self._cleanup_duplicate_tds_rows(None) + return + + if self.has_multiple_parties: + frappe.throw(_("Cannot apply TDS against multiple parties in one entry")) + + net_total = self._calculate_net_total() + if net_total <= 0: + return + + tds_details = self._get_tds_details(net_total) + if not tds_details or not tds_details.get("tax_amount"): + return + + self._create_or_update_tds_row(tds_details) + self._update_party_amount(tds_details.get("tax_amount"), is_reversal=False) + + self._recalculate_totals() + + def _should_apply_tds(self): + return self.doc.apply_tds and self.doc.voucher_type in ("Debit Note", "Credit Note") + + def _set_party_info(self): + for row in self.doc.get("accounts"): + if row.party_type in ("Customer", "Supplier") and row.party: + if self.party and row.party != self.party: + self.has_multiple_parties = True + + if not self.party: + self.party = row.party + self.party_type = row.party_type + self.party_account = row.account + self.party_row = row + + if row.get("is_tax_withholding_account"): + self.existing_tds_rows.append(row) + + return bool(self.party) + + def _setup_direction_fields(self): + """ + For Supplier (TDS): party has credit, TDS reduces credit + For Customer (TCS): party has debit, TCS increases debit + """ + if self.party_type == "Supplier": + self.party_field = "credit" + self.reverse_field = "debit" + else: # Customer + self.party_field = "debit" + self.reverse_field = "credit" + + self.precision = self.doc.precision(self.party_field, self.party_row) + + def _reset_existing_tds(self): + for row in self.existing_tds_rows: + # TDS amount is always in credit (liability to government) + tds_amount = flt(row.get("credit") - row.get("debit"), self.precision) + if not tds_amount: + continue + + self._update_party_amount(tds_amount, is_reversal=True) + + # zero_out_tds_row + row.update( + { + "credit": 0, + "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + } + ) + + def _update_party_amount(self, amount, is_reversal=False): + amount = flt(amount, self.precision) + amount_in_party_currency = flt(amount / self.party_row.get("exchange_rate", 1), self.precision) + + # Determine which field the party amount is in + active_field = self.party_field if self.party_row.get(self.party_field) else self.reverse_field + + # If amount is in reverse field, flip the signs + if active_field == self.reverse_field: + amount = -amount + amount_in_party_currency = -amount_in_party_currency + + # Direction multiplier based on party type: + # Customer (TCS): +1 (add to debit) + # Supplier (TDS): -1 (subtract from credit) + direction = 1 if self.party_type == "Customer" else -1 + + # Reversal inverts the direction + if is_reversal: + direction = -direction + + adjustment = amount * direction + adjustment_in_party_currency = amount_in_party_currency * direction + + active_field_account_currency = f"{active_field}_in_account_currency" + + self.party_row.update( + { + active_field: flt(self.party_row.get(active_field) + adjustment, self.precision), + active_field_account_currency: flt( + self.party_row.get(active_field_account_currency) + adjustment_in_party_currency, + self.precision, + ), + } + ) + + def _calculate_net_total(self): + from erpnext.accounts.report.general_ledger.general_ledger import get_account_type_map + + account_type_map = get_account_type_map(self.doc.company) + + return flt( + sum( + d.get(self.reverse_field) - d.get(self.party_field) + for d in self.doc.get("accounts") + if account_type_map.get(d.account) not in ("Tax", "Chargeable") + and d.account != self.party_account + and not d.get("is_tax_withholding_account") + ), + self.precision, + ) + + def _get_tds_details(self, net_total): + return get_party_tax_withholding_details( + frappe._dict( + { + "party_type": self.party_type, + "party": self.party, + "doctype": self.doc.doctype, + "company": self.doc.company, + "posting_date": self.doc.posting_date, + "tax_withholding_net_total": net_total, + "base_tax_withholding_net_total": net_total, + "grand_total": net_total, + } + ), + self.doc.tax_withholding_category, + ) + + def _create_or_update_tds_row(self, tds_details): + tax_account = tds_details.get("account_head") + account_currency = get_account_currency(tax_account) + company_currency = frappe.get_cached_value("Company", self.doc.company, "default_currency") + exchange_rate = _get_exchange_rate(account_currency, company_currency, self.doc.posting_date) + + tax_amount = flt(tds_details.get("tax_amount"), self.precision) + tax_amount_in_account_currency = flt(tax_amount / exchange_rate, self.precision) + + # Find existing TDS row for this account + tax_row = None + for row in self.doc.get("accounts"): + if row.account == tax_account and row.get("is_tax_withholding_account"): + tax_row = row + break + + if not tax_row: + tax_row = self.doc.append( + "accounts", + { + "account": tax_account, + "account_currency": account_currency, + "exchange_rate": exchange_rate, + "cost_center": tds_details.get("cost_center"), + "credit": 0, + "credit_in_account_currency": 0, + "debit": 0, + "debit_in_account_currency": 0, + "is_tax_withholding_account": 1, + }, + ) + + # TDS/TCS is always credited (liability to government) + tax_row.update( + { + "credit": tax_amount, + "credit_in_account_currency": tax_amount_in_account_currency, + "debit": 0, + "debit_in_account_currency": 0, + } + ) + + self._cleanup_duplicate_tds_rows(tax_row) + + def _cleanup_duplicate_tds_rows(self, current_tax_row): + rows_to_remove = [ + row + for row in self.doc.get("accounts") + if row.get("is_tax_withholding_account") and row != current_tax_row + ] + + for row in rows_to_remove: + self.doc.remove(row) + + def _recalculate_totals(self): + self.doc.set_amounts_in_company_currency() + self.doc.set_total_debit_credit() + self.doc.set_against_account() + + @frappe.whitelist() def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None): from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account @@ -1649,8 +1788,6 @@ def get_exchange_rate( credit=None, exchange_rate=None, ): - from erpnext.setup.utils import get_exchange_rate - account_details = frappe.get_cached_value( "Account", account, ["account_type", "root_type", "account_currency", "company"], as_dict=1 ) @@ -1672,8 +1809,8 @@ def get_exchange_rate( # The date used to retreive the exchange rate here is the date passed # in as an argument to this function. - elif (not exchange_rate or flt(exchange_rate) == 1) and account_currency and posting_date: - exchange_rate = get_exchange_rate(account_currency, company_currency, posting_date) + elif (not flt(exchange_rate) or flt(exchange_rate) == 1) and account_currency and posting_date: + exchange_rate = _get_exchange_rate(account_currency, company_currency, posting_date) else: exchange_rate = 1 diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index 0b520ecd0e4..a0535c4e1ca 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -34,6 +34,7 @@ "reference_detail_no", "advance_voucher_type", "advance_voucher_no", + "is_tax_withholding_account", "col_break3", "is_advance", "user_remark", @@ -282,12 +283,19 @@ "options": "advance_voucher_type", "read_only": 1, "search_index": 1 + }, + { + "default": "0", + "fieldname": "is_tax_withholding_account", + "fieldtype": "Check", + "label": "Is Tax Withholding Account", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2025-10-27 13:48:32.805100", + "modified": "2025-11-27 12:23:33.157655", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py index b801ac8c9a5..d26224103c0 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py @@ -28,6 +28,7 @@ class JournalEntryAccount(Document): debit_in_account_currency: DF.Currency exchange_rate: DF.Float is_advance: DF.Literal["No", "Yes"] + is_tax_withholding_account: DF.Check parent: DF.Data parentfield: DF.Data parenttype: DF.Data diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e6f2b2733dd..5737956fe86 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -850,6 +850,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False): ) referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests} + doc_updates = {} for ref in references: if not ref.payment_request: @@ -875,7 +876,7 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False): title=_("Invalid Allocated Amount"), ) - # update status + # determine status if new_outstanding_amount == payment_request["grand_total"]: status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested" elif new_outstanding_amount == 0: @@ -883,12 +884,15 @@ def update_payment_requests_as_per_pe_references(references=None, cancel=False): elif new_outstanding_amount > 0: status = "Partially Paid" - # update database - frappe.db.set_value( - "Payment Request", - ref.payment_request, - {"outstanding_amount": new_outstanding_amount, "status": status}, - ) + # prepare bulk update data + doc_updates[ref.payment_request] = { + "outstanding_amount": new_outstanding_amount, + "status": status, + } + + # bulk update all payment requests + if doc_updates: + frappe.db.bulk_update("Payment Request", doc_updates) def get_dummy_message(doc): diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 9a831598328..2e7e0314aba 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -126,8 +126,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } } - if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { - cur_frm.add_custom_button( + if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { + this.frm.add_custom_button( __("Payment Request"), function () { me.make_payment_request(); diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 3dcbcbfc2ab..56472e04430 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1349,7 +1349,11 @@ class SalesInvoice(SellingController): ) for item in self.get("items"): - if flt(item.base_net_amount, item.precision("base_net_amount")) or item.is_fixed_asset: + if ( + flt(item.base_net_amount, item.precision("base_net_amount")) + or item.is_fixed_asset + or enable_discount_accounting + ): # Do not book income for transfer within same company if self.is_internal_transfer(): continue diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index fa9b226374c..97178f95281 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -85,6 +85,9 @@ def get_party_details(inv): if inv.doctype == "Sales Invoice": party_type = "Customer" party = inv.customer + elif inv.doctype == "Journal Entry": + party_type = inv.party_type + party = inv.party else: party_type = "Supplier" party = inv.supplier @@ -155,7 +158,7 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None): party_type, parties, inv, tax_details, posting_date, pan_no ) - if party_type == "Supplier": + if party_type == "Supplier" or inv.doctype == "Journal Entry": tax_row = get_tax_row_for_tds(tax_details, tax_amount) else: tax_row = get_tax_row_for_tcs(inv, tax_details, tax_amount, tax_deducted) @@ -346,7 +349,10 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N elif party_type == "Customer": if tax_deducted: # if already TCS is charged, then amount will be calculated based on 'Previous Row Total' - tax_amount = 0 + if inv.doctype == "Sales Invoice": + tax_amount = 0 + else: + tax_amount = inv.base_tax_withholding_net_total * tax_details.rate / 100 else: # if no TCS has been charged in FY, # then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold @@ -718,7 +724,7 @@ def get_advance_adjusted_in_invoice(inv): def get_invoice_total_without_tcs(inv, tax_details): - tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head] + tcs_tax_row = [d for d in inv.get("taxes") or [] if d.account_head == tax_details.account_head] tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0 return inv.grand_total - tcs_tax_row_amount diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index 3ea6801ad35..3096a25c3ed 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -848,6 +848,90 @@ class TestTaxWithholdingCategory(FrappeTestCase): self.assertEqual(payment.taxes[0].tax_amount, 6000) self.assertEqual(payment.taxes[0].allocated_amount, 6000) + def test_tds_on_journal_entry_for_supplier(self): + """Test TDS deduction for Supplier in Debit Note""" + frappe.db.set_value( + "Supplier", "Test TDS Supplier", "tax_withholding_category", "Cumulative Threshold TDS" + ) + + jv = make_journal_entry_with_tax_withholding( + party_type="Supplier", + party="Test TDS Supplier", + voucher_type="Debit Note", + amount=50000, + save=False, + ) + jv.apply_tds = 1 + jv.tax_withholding_category = "Cumulative Threshold TDS" + jv.save() + + # Again saving should not change tds amount + jv.user_remark = "Test TDS on Journal Entry for Supplier" + jv.save() + jv.submit() + + # TDS = 50000 * 10% = 5000 + self.assertEqual(len(jv.accounts), 3) + + # Find TDS account row + tds_row = None + supplier_row = None + for row in jv.accounts: + if row.account == "TDS - _TC": + tds_row = row + elif row.party == "Test TDS Supplier": + supplier_row = row + + self.assertEqual(tds_row.credit, 5000) + self.assertEqual(tds_row.debit, 0) + + # Supplier amount should be reduced by TDS + self.assertEqual(supplier_row.credit, 45000) + jv.cancel() + + def test_tcs_on_journal_entry_for_customer(self): + """Test TCS collection for Customer in Credit Note""" + frappe.db.set_value( + "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" + ) + + # Create Credit Note with amount exceeding threshold + jv = make_journal_entry_with_tax_withholding( + party_type="Customer", + party="Test TCS Customer", + voucher_type="Credit Note", + amount=50000, + save=False, + ) + jv.apply_tds = 1 + jv.tax_withholding_category = "Cumulative Threshold TCS" + jv.save() + + # Again saving should not change tds amount + jv.user_remark = "Test TCS on Journal Entry for Customer" + jv.save() + jv.submit() + + # Assert TCS calculation (10% on amount above threshold of 30000) + self.assertEqual(len(jv.accounts), 3) + + # Find TCS account row + tcs_row = None + customer_row = None + for row in jv.accounts: + if row.account == "TCS - _TC": + tcs_row = row + elif row.party == "Test TCS Customer": + customer_row = row + + # TCS should be credited (liability to government) + self.assertEqual(tcs_row.credit, 2000) # above threshold 20000*10% + self.assertEqual(tcs_row.debit, 0) + + # Customer amount should be increased by TCS + self.assertEqual(customer_row.debit, 52000) + jv.cancel() + def cancel_invoices(): purchase_invoices = frappe.get_all( @@ -996,6 +1080,88 @@ def create_payment_entry(**args): return pe +def make_journal_entry_with_tax_withholding( + party_type, + party, + voucher_type, + amount, + cost_center=None, + posting_date=None, + save=True, + submit=False, +): + """Helper function to create Journal Entry for tax withholding""" + if not cost_center: + cost_center = "_Test Cost Center - _TC" + + jv = frappe.new_doc("Journal Entry") + jv.posting_date = posting_date or today() + jv.company = "_Test Company" + jv.voucher_type = voucher_type + jv.multi_currency = 0 + + if party_type == "Supplier": + # Debit Note: Expense Dr, Supplier Cr + expense_account = "Stock Received But Not Billed - _TC" + party_account = "Creditors - _TC" + + jv.append( + "accounts", + { + "account": expense_account, + "cost_center": cost_center, + "debit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + jv.append( + "accounts", + { + "account": party_account, + "party_type": party_type, + "party": party, + "cost_center": cost_center, + "credit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + else: # Customer + # Credit Note: Customer Dr, Income Cr + party_account = "Debtors - _TC" + income_account = "Sales - _TC" + + jv.append( + "accounts", + { + "account": party_account, + "party_type": party_type, + "party": party, + "cost_center": cost_center, + "debit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + jv.append( + "accounts", + { + "account": income_account, + "cost_center": cost_center, + "credit_in_account_currency": amount, + "exchange_rate": 1, + }, + ) + + if save or submit: + jv.insert() + + if submit: + jv.submit() + + return jv + + def create_records(): # create a new suppliers for name in [ diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index c9d37b9ad1a..1ed08d79cc6 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -5,7 +5,6 @@ import frappe from frappe import _ from frappe.utils import flt -from pypika import Order import erpnext from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import ( @@ -16,7 +15,7 @@ from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register i get_group_by_and_display_fields, get_tax_accounts, ) -from erpnext.accounts.report.utils import get_query_columns, get_values_for_columns +from erpnext.accounts.report.utils import get_values_for_columns def execute(filters=None): @@ -41,16 +40,6 @@ def _execute(filters=None, additional_table_columns=None): tax_doctype="Purchase Taxes and Charges", ) - scrubbed_tax_fields = {} - - for tax in tax_columns: - scrubbed_tax_fields.update( - { - tax + " Rate": frappe.scrub(tax + " Rate"), - tax + " Amount": frappe.scrub(tax + " Amount"), - } - ) - po_pr_map = get_purchase_receipts_against_purchase_order(item_list) data = [] @@ -100,8 +89,8 @@ def _execute(filters=None, additional_table_columns=None): item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update( { - scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0), - scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0), + f"{tax}_rate": item_tax.get("tax_rate", 0), + f"{tax}_amount": item_tax.get("tax_amount", 0), } ) total_tax += flt(item_tax.get("tax_amount")) diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index d234e5bc328..d326541631a 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -6,7 +6,7 @@ import frappe from frappe import _ from frappe.model.meta import get_field_precision from frappe.query_builder import functions as fn -from frappe.utils import cstr, flt +from frappe.utils import flt from frappe.utils.nestedset import get_descendants_of from frappe.utils.xlsxutils import handle_html @@ -32,16 +32,6 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions= if item_list: itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency) - scrubbed_tax_fields = {} - - for tax in tax_columns: - scrubbed_tax_fields.update( - { - tax + " Rate": frappe.scrub(tax + " Rate"), - tax + " Amount": frappe.scrub(tax + " Amount"), - } - ) - mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list)) so_dn_map = get_delivery_notes_against_sales_order(item_list) @@ -102,8 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions= item_tax = itemised_tax.get(d.name, {}).get(tax, {}) row.update( { - scrubbed_tax_fields[tax + " Rate"]: item_tax.get("tax_rate", 0), - scrubbed_tax_fields[tax + " Amount"]: item_tax.get("tax_amount", 0), + f"{tax}_rate": item_tax.get("tax_rate", 0), + f"{tax}_amount": item_tax.get("tax_amount", 0), } ) if item_tax.get("is_other_charges"): @@ -546,9 +536,10 @@ def get_tax_accounts( import json item_row_map = {} - tax_columns = [] + tax_columns = {} invoice_item_row = {} itemised_tax = {} + scrubbed_description_map = {} add_deduct_tax = "charge_type" tax_amount_precision = ( @@ -605,9 +596,14 @@ def get_tax_accounts( tax_amount, ) in tax_details: description = handle_html(description) - if description not in tax_columns and tax_amount: + scrubbed_description = scrubbed_description_map.get(description) + if not scrubbed_description: + scrubbed_description = frappe.scrub(description) + scrubbed_description_map[description] = scrubbed_description + + if scrubbed_description not in tax_columns and tax_amount: # as description is text editor earlier and markup can break the column convention in reports - tax_columns.append(description) + tax_columns[scrubbed_description] = description if item_wise_tax_detail: try: @@ -641,7 +637,7 @@ def get_tax_accounts( else tax_value ) - itemised_tax.setdefault(d.name, {})[description] = frappe._dict( + itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict( { "tax_rate": tax_rate, "tax_amount": tax_value, @@ -653,7 +649,7 @@ def get_tax_accounts( continue elif charge_type == "Actual" and tax_amount: for d in invoice_item_row.get(parent, []): - itemised_tax.setdefault(d.name, {})[description] = frappe._dict( + itemised_tax.setdefault(d.name, {})[scrubbed_description] = frappe._dict( { "tax_rate": "NA", "tax_amount": flt( @@ -662,12 +658,14 @@ def get_tax_accounts( } ) - tax_columns.sort() - for desc in tax_columns: + tax_columns_list = list(tax_columns.keys()) + tax_columns_list.sort() + for scrubbed_desc in tax_columns_list: + desc = tax_columns[scrubbed_desc] columns.append( { "label": _(desc + " Rate"), - "fieldname": frappe.scrub(desc + " Rate"), + "fieldname": f"{scrubbed_desc}_rate", "fieldtype": "Float", "width": 100, } @@ -676,7 +674,7 @@ def get_tax_accounts( columns.append( { "label": _(desc + " Amount"), - "fieldname": frappe.scrub(desc + " Amount"), + "fieldname": f"{scrubbed_desc}_amount", "fieldtype": "Currency", "options": "currency", "width": 100, @@ -714,7 +712,7 @@ def get_tax_accounts( }, ] - return itemised_tax, tax_columns + return itemised_tax, tax_columns_list def add_total_row( @@ -807,5 +805,5 @@ def add_sub_total_row(item, total_row_map, group_by_value, tax_columns): total_row["percent_gt"] += item["percent_gt"] for tax in tax_columns: - total_row.setdefault(frappe.scrub(tax + " Amount"), 0.0) - total_row[frappe.scrub(tax + " Amount")] += flt(item[frappe.scrub(tax + " Amount")]) + total_row.setdefault(f"{tax}_amount", 0.0) + total_row[f"{tax}_amount"] += flt(item[f"{tax}_amount"]) diff --git a/erpnext/assets/doctype/asset/depreciation.py b/erpnext/assets/doctype/asset/depreciation.py index c00fc86d4b2..ea8059b68a5 100644 --- a/erpnext/assets/doctype/asset/depreciation.py +++ b/erpnext/assets/doctype/asset/depreciation.py @@ -537,6 +537,7 @@ def modify_depreciation_schedule_for_asset_repairs(asset, notes): for repair in asset_repairs: if repair.increase_in_asset_life: asset_repair = frappe.get_doc("Asset Repair", repair.name) + asset_repair.asset_doc = asset asset_repair.modify_depreciation_schedule() make_new_active_asset_depr_schedules_and_cancel_current_ones(asset, notes) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index ab3bb9ab406..4179ceccb59 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -139,6 +139,7 @@ class AssetCapitalization(StockController): self.make_gl_entries() self.repost_future_sle_and_gle() self.restore_consumed_asset_items() + self.update_target_asset() def set_title(self): self.title = self.target_asset_name or self.target_item_name or self.target_item_code @@ -607,8 +608,12 @@ class AssetCapitalization(StockController): total_target_asset_value = flt(self.total_value, self.precision("total_value")) asset_doc = frappe.get_doc("Asset", self.target_asset) - asset_doc.gross_purchase_amount += total_target_asset_value - asset_doc.purchase_amount += total_target_asset_value + if self.docstatus == 2: + asset_doc.gross_purchase_amount -= total_target_asset_value + asset_doc.purchase_amount -= total_target_asset_value + else: + asset_doc.gross_purchase_amount += total_target_asset_value + asset_doc.purchase_amount += total_target_asset_value asset_doc.set_status("Work In Progress") asset_doc.flags.ignore_validate = True asset_doc.save() diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 0e94cfd95de..a7924288058 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -86,6 +86,7 @@ status_map = { ["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], + ["Return", "eval:self.is_return == 1 and self.per_billed == 0 and self.docstatus == 1"], ["Cancelled", "eval:self.docstatus==2"], ["Closed", "eval:self.status=='Closed' and self.docstatus != 2"], ], diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index eda94351515..d6c4b99588c 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _, scrub from frappe.model.document import Document +from frappe.query_builder import functions from frappe.utils import cint, flt, round_based_on_smallest_currency_fraction from frappe.utils.deprecations import deprecated @@ -685,6 +686,22 @@ class calculate_taxes_and_totals: discount_amount = self.doc.discount_amount or 0 grand_total = self.doc.grand_total + if self.doc.get("is_return") and self.doc.get("return_against"): + doctype = frappe.qb.DocType(self.doc.doctype) + + result = ( + frappe.qb.from_(doctype) + .select(functions.Sum(doctype.discount_amount).as_("total_return_discount")) + .where( + (doctype.return_against == self.doc.return_against) + & (doctype.is_return == 1) + & (doctype.docstatus == 1) + ) + ).run(as_dict=True) + + total_return_discount = abs(result[0].get("total_return_discount") or 0) + discount_amount += total_return_discount + # validate that discount amount cannot exceed the total before discount if ( (grand_total >= 0 and discount_amount > grand_total) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index b6c1f636b18..313a9de4e4b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -389,10 +389,12 @@ frappe.ui.form.on("BOM", { ); has_template_rm.forEach((d) => { + let bom_qty = dialog.fields_dict.qty?.value || 1; + dialog.fields_dict.items.df.data.push({ item_code: d.item_code, variant_item_code: "", - qty: (d.qty / frm.doc.quantity) * (dialog.fields_dict.qty.value || 1), + qty: flt(d.qty / frm.doc.quantity) * flt(bom_qty), source_warehouse: d.source_warehouse, operation: d.operation, }); diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index c34651f9ef1..e3ee2baaa53 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -178,17 +178,12 @@ class JobCard(Document): if job_card_qty and ((job_card_qty - completed_qty) > wo_qty): form_link = get_link_to_form("Manufacturing Settings", "Manufacturing Settings") - - msg = f""" - Qty To Manufacture in the job card - cannot be greater than Qty To Manufacture in the - work order for the operation {bold(self.operation)}. -

Solution: Either you can reduce the - Qty To Manufacture in the job card or set the - 'Overproduction Percentage For Work Order' - in the {form_link}.""" - - frappe.throw(_(msg), title=_("Extra Job Card Quantity")) + frappe.throw( + _( + "Qty To Manufacture in the job card cannot be greater than Qty To Manufacture in the work order for the operation {0}.

Solution: Either you can reduce the Qty To Manufacture in the job card or set the 'Overproduction Percentage For Work Order' in the {1}." + ).format(bold(self.operation), form_link), + title=_("Extra Job Card Quantity"), + ) def set_sub_operations(self): if not self.sub_operations and self.operation: @@ -1064,14 +1059,16 @@ class JobCard(Document): ) if row.completed_qty < current_operation_qty: - msg = f"""The completed quantity {bold(current_operation_qty)} - of an operation {bold(self.operation)} cannot be greater - than the completed quantity {bold(row.completed_qty)} - of a previous operation - {bold(row.operation)}. - """ - - frappe.throw(_(msg)) + frappe.throw( + _( + "The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}." + ).format( + bold(current_operation_qty), + bold(self.operation), + bold(row.completed_qty), + bold(row.operation), + ) + ) def validate_work_order(self): if self.is_work_order_closed(): diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 9c4ceeaee3b..5fe7c607b6c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -229,7 +229,8 @@ frappe.ui.form.on("Work Order", { if ( frm.doc.docstatus === 1 && ["Closed", "Completed"].includes(frm.doc.status) && - frm.doc.produced_qty > 0 + frm.doc.produced_qty > 0 && + frm.doc.produced_qty > frm.doc.disassembled_qty ) { frm.add_custom_button( __("Disassemble Order"), @@ -406,7 +407,6 @@ frappe.ui.form.on("Work Order", { work_order_id: frm.doc.name, purpose: "Disassemble", qty: data.qty, - target_warehouse: data.target_warehouse, }); }) .then((stock_entry) => { @@ -863,24 +863,6 @@ erpnext.work_order = { }, ]; - if (purpose === "Disassemble") { - fields.push({ - fieldtype: "Link", - options: "Warehouse", - fieldname: "target_warehouse", - label: __("Target Warehouse"), - default: frm.doc.source_warehouse || frm.doc.wip_warehouse, - get_query() { - return { - filters: { - company: frm.doc.company, - is_group: 0, - }, - }; - }, - }); - } - return new Promise((resolve, reject) => { frm.qty_prompt = frappe.prompt( fields, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 9d862e84da7..5e4dcf22431 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1373,6 +1373,13 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m item_details = get_item_details(item, project) + if frappe.db.get_value("Item", item, "variant_of"): + if variant_bom := frappe.db.get_value( + "BOM", + {"item": item, "is_default": 1, "docstatus": 1}, + ): + bom_no = variant_bom + wo_doc = frappe.new_doc("Work Order") wo_doc.production_item = item wo_doc.update(item_details) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 3abcde36072..f61de798390 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1093,6 +1093,14 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.refresh_field("payment_schedule"); } + cost_center(doc) { + this.frm.doc.items.forEach((item) => { + item.cost_center = doc.cost_center; + }); + + this.frm.refresh_field("items"); + } + due_date(doc, cdt, cdn) { // due_date is to be changed, payment terms template and/or payment schedule must // be removed as due_date is automatically changed based on payment terms diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index bdfbbb93916..97873234dcf 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -1091,7 +1091,7 @@ "no_copy": 1, "oldfieldname": "status", "oldfieldtype": "Select", - "options": "\nDraft\nTo Bill\nCompleted\nReturn Issued\nCancelled\nClosed", + "options": "\nDraft\nTo Bill\nCompleted\nReturn\nReturn Issued\nCancelled\nClosed", "print_hide": 1, "print_width": "150px", "read_only": 1, @@ -1404,7 +1404,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2025-08-04 19:20:47.724218", + "modified": "2025-12-02 23:55:25.415443", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index 4a0d4048b78..f2c6be169be 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -126,7 +126,9 @@ class DeliveryNote(SellingController): shipping_address_name: DF.Link | None shipping_rule: DF.Link | None source: DF.Link | None - status: DF.Literal["", "Draft", "To Bill", "Completed", "Return Issued", "Cancelled", "Closed"] + status: DF.Literal[ + "", "Draft", "To Bill", "Completed", "Return", "Return Issued", "Cancelled", "Closed" + ] tax_category: DF.Link | None tax_id: DF.Data | None taxes: DF.Table[SalesTaxesandCharges] diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index af40fd6a8a2..0f045bf405d 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -12,8 +12,8 @@ frappe.listview_settings["Delivery Note"] = { "currency", ], get_indicator: function (doc) { - if (cint(doc.is_return) == 1) { - return [__("Return"), "gray", "is_return,=,Yes"]; + if (cint(doc.is_return) == 1 && doc.status == "Return") { + return [__("Return"), "gray", "is_return,=,1"]; } else if (doc.status === "Closed") { return [__("Closed"), "green", "status,=,Closed"]; } else if (doc.status === "Return Issued") { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 450c2243620..5e28362c509 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -2581,6 +2581,7 @@ class TestDeliveryNote(FrappeTestCase): dn = make_delivery_note(so.name) dn.submit() self.assertEqual(dn.per_billed, 0) + self.assertEqual(dn.status, "To Bill") si = make_sales_invoice(dn.name) si.location = "Test Location" @@ -2595,6 +2596,7 @@ class TestDeliveryNote(FrappeTestCase): dn.load_from_db() self.assertEqual(dn.per_billed, 100) self.assertEqual(dn.per_returned, 100) + self.assertEqual(returned.status, "Return") def test_sales_return_for_product_bundle(self): from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 2cea6463592..6ec2f4957d9 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -224,7 +224,7 @@ frappe.ui.form.on("Item", { ["is_stock_item", "has_serial_no", "has_batch_no", "has_variants"].forEach((fieldname) => { frm.set_df_property(fieldname, "read_only", stock_exists); }); - + frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0); frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0); frm.set_query("item_group", () => { return { diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index f0923061e93..1b9ddd13f2c 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -243,8 +243,7 @@ "default": "0", "fieldname": "is_fixed_asset", "fieldtype": "Check", - "label": "Is Fixed Asset", - "set_only_once": 1 + "label": "Is Fixed Asset" }, { "allow_in_quick_entry": 1, @@ -895,7 +894,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2025-08-08 14:58:48.674193", + "modified": "2025-12-04 09:11:56.029567", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index e71c8eb6a00..fdaaa23c5af 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -155,6 +155,7 @@ class Item(Document): self.set_onload("stock_exists", self.stock_ledger_created()) self.set_onload("asset_naming_series", get_asset_naming_series()) self.set_onload("current_valuation_method", get_valuation_method(self.name)) + self.set_onload("asset_exists", self.has_submitted_assets()) def autoname(self): if frappe.db.get_default("item_naming_by") == "Naming Series": @@ -306,9 +307,8 @@ class Item(Document): if self.stock_ledger_created(): frappe.throw(_("Cannot be a fixed asset item as Stock Ledger is created.")) - if not self.is_fixed_asset: - asset = frappe.db.get_all("Asset", filters={"item_code": self.name, "docstatus": 1}, limit=1) - if asset: + if not self.is_fixed_asset and not self.is_new(): + if self.has_submitted_assets(): frappe.throw( _('"Is Fixed Asset" cannot be unchecked, as Asset record exists against the item') ) @@ -525,6 +525,9 @@ class Item(Document): ) return self._stock_ledger_created + def has_submitted_assets(self): + return bool(frappe.db.exists("Asset", {"item_code": self.name, "docstatus": 1})) + def update_item_price(self): frappe.db.sql( """ diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index e2cfb8eeca8..8d251d143a9 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -743,7 +743,10 @@ class PickList(TransactionBase): pi_item.serial_no, ( Case() - .when((pi_item.picked_qty > 0) & (pi_item.docstatus == 1), pi_item.picked_qty) + .when( + (pi_item.picked_qty > 0) & (pi_item.docstatus == 1), + pi_item.picked_qty - pi_item.delivered_qty, + ) .else_(pi_item.stock_qty) ).as_("picked_qty"), ) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index b190e30227f..6e8c7abec7c 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -645,6 +645,46 @@ class TestPickList(FrappeTestCase): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) + def test_picklist_reserved_qty_validation(self): + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + warehouse = "_Test Warehouse - _TC" + test_stock_item = "_Test Stock Item" + + # Ensure stock item exists + if not frappe.db.exists("Item", test_stock_item): + create_item( + item_code=test_stock_item, + is_stock_item=1, + ) + + # Add initial stock qty + make_stock_entry(item_code=test_stock_item, to_warehouse=warehouse, qty=15) + + # Create SO for 10 qty + sales_order_1 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10) + + # Create and Submit picklist for SO + picklist_1 = create_pick_list(sales_order_1.name) + picklist_1.submit() + + # Create DN for 5 qty + dn = create_delivery_note(picklist_1.name) + dn.items[0].qty = 5 + dn.save() + dn.submit() + + # Verify partly delivered state + picklist_1.reload() + self.assertEqual(picklist_1.status, "Partly Delivered") + + # Create another SO (10 qty) + sales_order_2 = make_sales_order(item_code=test_stock_item, warehouse=warehouse, qty=10) + + # Expected pick qty = 5 + picklist_2 = create_pick_list(sales_order_2.name) + self.assertEqual(picklist_2.locations[0].qty, 5) + def test_picklist_with_multi_uom(self): warehouse = "_Test Warehouse - _TC" item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js index 30562e23de8..27b7cfec557 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js @@ -12,7 +12,7 @@ frappe.listview_settings["Purchase Receipt"] = { ], get_indicator: function (doc) { if (cint(doc.is_return) == 1 && doc.status == "Return") { - return [__("Return"), "gray", "is_return,=,Yes"]; + return [__("Return"), "gray", "is_return,=,1"]; } else if (doc.status === "Closed") { return [__("Closed"), "green", "status,=,Closed"]; } else if (flt(doc.per_returned, 2) === 100) { diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 959c58b2380..14d6ee7700d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4448,6 +4448,87 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(srbnb_cost, 1000) + def test_lcv_for_repack_entry(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + create_landed_cost_voucher, + ) + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + for item in [ + "Potatoes Raw Material Item", + "Fries Finished Goods Item", + ]: + create_item(item) + + pr = make_purchase_receipt( + item_code="Potatoes Raw Material Item", + warehouse="_Test Warehouse - _TC", + qty=100, + rate=50, + ) + + wh1 = create_warehouse("WH A1", company=pr.company) + wh2 = create_warehouse("WH A2", company=pr.company) + + ste = make_stock_entry( + purpose="Repack", + source="_Test Warehouse - _TC", + item_code="Potatoes Raw Material Item", + qty=100, + company=pr.company, + do_not_save=1, + ) + + ste.append( + "items", + { + "item_code": "Fries Finished Goods Item", + "qty": 50, + "t_warehouse": wh1, + }, + ) + + ste.append( + "items", + { + "item_code": "Fries Finished Goods Item", + "qty": 50, + "t_warehouse": wh2, + }, + ) + + ste.insert() + ste.submit() + ste.reload() + + for row in ste.items: + if row.t_warehouse: + self.assertEqual(row.valuation_rate, 50) + + sles = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)}, + pluck="stock_value_difference", + ) + + self.assertEqual(sles, [2500.0, 2500.0]) + + create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=2000 * -1) + + ste.reload() + + for row in ste.items: + if row.t_warehouse: + self.assertEqual(row.valuation_rate, 30) + + sles = frappe.get_all( + "Stock Ledger Entry", + filters={"voucher_type": ste.doctype, "voucher_no": ste.name, "actual_qty": (">", 0)}, + pluck="stock_value_difference", + ) + + self.assertEqual(sles, [1500.0, 1500.0]) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index d9216fbb9a1..69bc03a8bd4 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -9,7 +9,7 @@ frappe.ui.form.on("Quality Inspection", { }, set_default_company(frm) { - if (!frm.doc.company) { + if (frm.doc.docstatus === 0 && !frm.doc.company) { frm.set_value("company", frappe.defaults.get_default("company")); } }, diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 46c3b180954..d53382b7047 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2532,6 +2532,9 @@ def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> dict[str, dict]: child_row = group_by_voucher[key] if row.serial_no: child_row["serial_nos"].append(row.serial_no) + child_row["item_row"].qty = len(child_row["serial_nos"]) * ( + -1 if row.type_of_transaction == "Outward" else 1 + ) if row.batch_no: child_row["batch_nos"][row.batch_no] += row.qty diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 6a6bb4c95b4..6833036122f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -571,6 +571,14 @@ frappe.ui.form.on("Stock Entry", { } }, + set_rate_and_fg_qty: function (frm, cdt, cdn) { + frm.events.set_basic_rate(frm, cdt, cdn); + let item = frappe.get_doc(cdt, cdn); + if (item.is_finished_item) { + frm.events.set_fg_completed_qty(frm); + } + }, + get_warehouse_details: function (frm, cdt, cdn) { var child = locals[cdt][cdn]; if (!child.bom_no) { @@ -833,7 +841,7 @@ frappe.ui.form.on("Stock Entry", { frm.doc.items.forEach((item) => { if (item.is_finished_item) { - fg_completed_qty += flt(item.qty); + fg_completed_qty += flt(item.transfer_qty); } }); @@ -859,15 +867,11 @@ frappe.ui.form.on("Stock Entry Detail", { }, qty(frm, cdt, cdn) { - frm.events.set_basic_rate(frm, cdt, cdn); - let item = frappe.get_doc(cdt, cdn); - if (item.is_finished_item) { - frm.events.set_fg_completed_qty(frm); - } + frm.events.set_rate_and_fg_qty(frm, cdt, cdn); }, conversion_factor(frm, cdt, cdn) { - frm.events.set_basic_rate(frm, cdt, cdn); + frm.events.set_rate_and_fg_qty(frm, cdt, cdn); }, s_warehouse(frm, cdt, cdn) { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e221595061d..942a9f8133c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -243,7 +243,66 @@ class StockEntry(StockController): self.validate_same_source_target_warehouse_during_material_transfer() + def set_serial_batch_for_disassembly(self): + if self.purpose != "Disassemble": + return + + available_materials = get_available_materials(self.work_order, self) + for row in self.items: + warehouse = row.s_warehouse or row.t_warehouse + materials = available_materials.get((row.item_code, warehouse)) + if not materials: + continue + + batches = defaultdict(float) + serial_nos = [] + qty = row.transfer_qty + for batch_no, batch_qty in materials.batch_details.items(): + if qty <= 0: + break + + batch_qty = abs(batch_qty) + if batch_qty <= qty: + batches[batch_no] = batch_qty + qty -= batch_qty + else: + batches[batch_no] = qty + qty = 0 + + if materials.serial_nos: + serial_nos = materials.serial_nos[: int(row.transfer_qty)] + + if not serial_nos and not batches: + continue + + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": row.transfer_qty, + "type_of_transaction": "Inward" if row.t_warehouse else "Outward", + "company": self.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) + + row.serial_and_batch_bundle = bundle_doc.name + row.use_serial_batch_fields = 0 + + row.db_set( + { + "serial_and_batch_bundle": bundle_doc.name, + "use_serial_batch_fields": 0, + } + ) + def on_submit(self): + self.set_serial_batch_for_disassembly() self.validate_closed_subcontracting_order() self.make_bundle_using_old_serial_batch_fields() self.update_disassembled_order() @@ -1856,7 +1915,13 @@ class StockEntry(StockController): s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") - items_dict = get_bom_items_as_dict(self.bom_no, self.company, disassemble_qty) + items_dict = get_bom_items_as_dict( + self.bom_no, + self.company, + disassemble_qty, + fetch_exploded=self.use_multi_level_bom, + fetch_qty_in_stock_uom=False, + ) for row in items: child_row = self.append("items", {}) @@ -1874,7 +1939,7 @@ class StockEntry(StockController): child_row.qty = disassemble_qty child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" - child_row.t_warehouse = self.to_warehouse if not row.is_finished_item else "" + child_row.t_warehouse = row.s_warehouse child_row.is_finished_item = 0 if row.is_finished_item else 1 def get_items_from_manufacture_entry(self): @@ -1893,6 +1958,8 @@ class StockEntry(StockController): "`tabStock Entry Detail`.`is_finished_item`", "`tabStock Entry Detail`.`batch_no`", "`tabStock Entry Detail`.`serial_no`", + "`tabStock Entry Detail`.`s_warehouse`", + "`tabStock Entry Detail`.`t_warehouse`", "`tabStock Entry Detail`.`use_serial_batch_fields`", ], filters=[ @@ -3259,8 +3326,8 @@ def get_items_from_subcontract_order(source_name, target_doc=None): return target_doc -def get_available_materials(work_order) -> dict: - data = get_stock_entry_data(work_order) +def get_available_materials(work_order, stock_entry_doc=None) -> dict: + data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc) available_materials = {} for row in data: @@ -3268,6 +3335,9 @@ def get_available_materials(work_order) -> dict: if row.purpose != "Material Transfer for Manufacture": key = (row.item_code, row.s_warehouse) + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + key = (row.item_code, row.s_warehouse or row.warehouse) + if key not in available_materials: available_materials.setdefault( key, @@ -3278,7 +3348,9 @@ def get_available_materials(work_order) -> dict: item_data = available_materials[key] - if row.purpose == "Material Transfer for Manufacture": + if row.purpose == "Material Transfer for Manufacture" or ( + stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture" + ): item_data.qty += row.qty if row.batch_no: item_data.batch_details[row.batch_no] += row.qty @@ -3318,7 +3390,7 @@ def get_available_materials(work_order) -> dict: return available_materials -def get_stock_entry_data(work_order): +def get_stock_entry_data(work_order, stock_entry_doc=None): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_voucher_wise_serial_batch_from_bundle, ) @@ -3350,19 +3422,35 @@ def get_stock_entry_data(work_order): (stock_entry.name == stock_entry_detail.parent) & (stock_entry.work_order == work_order) & (stock_entry.docstatus == 1) - & (stock_entry_detail.s_warehouse.isnotnull()) - & ( - stock_entry.purpose.isin( - [ - "Manufacture", - "Material Consumption for Manufacture", - "Material Transfer for Manufacture", - ] - ) - ) ) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) - ).run(as_dict=1) + ) + + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + data = data.where( + stock_entry.purpose.isin( + [ + "Disassemble", + "Manufacture", + ] + ) + ) + + data = data.where(stock_entry.name != stock_entry_doc.name) + else: + data = data.where( + stock_entry.purpose.isin( + [ + "Manufacture", + "Material Consumption for Manufacture", + "Material Transfer for Manufacture", + ] + ) + ) + + data = data.where(stock_entry_detail.s_warehouse.isnotnull()) + + data = data.run(as_dict=1) if not data: return [] @@ -3375,6 +3463,9 @@ def get_stock_entry_data(work_order): if row.purpose != "Material Transfer for Manufacture": key = (row.item_code, row.s_warehouse, row.name) + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + key = (row.item_code, row.s_warehouse or row.warehouse, row.name) + if bundle_data.get(key): row.update(bundle_data.get(key)) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 8b61ca56983..407040c245c 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -647,10 +647,32 @@ class update_entries_after: if sle.dependant_sle_voucher_detail_no: entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) + if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no): + # for repack entries, we need to repost both source and target warehouses + self.update_distinct_item_warehouses_for_repack(sle) if self.exceptions: self.raise_exceptions() + def update_distinct_item_warehouses_for_repack(self, sle): + sles = ( + frappe.get_all( + "Stock Ledger Entry", + filters={ + "voucher_type": "Stock Entry", + "voucher_no": sle.voucher_no, + "actual_qty": (">", 0), + "is_cancelled": 0, + "voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no), + }, + fields=["*"], + ) + or [] + ) + + for dependant_sle in sles: + self.update_distinct_item_warehouses(dependant_sle) + def has_stock_reco_with_serial_batch(self, sle): if ( sle.voucher_type == "Stock Reconciliation" @@ -696,6 +718,13 @@ class update_entries_after: {"item_code": self.item_code, "warehouse": self.args.warehouse} ) + key = (self.item_code, self.args.warehouse) + if key in self.distinct_item_warehouses and self.distinct_item_warehouses[key].get( + "transfer_entry_to_repost" + ): + # only repost stock entries + args["filter_voucher_type"] = "Stock Entry" + return list(self.get_sle_after_datetime(args)) def get_dependent_entries_to_fix(self, entries_to_fix, sle): @@ -729,8 +758,10 @@ class update_entries_after: if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date): self.distinct_item_warehouses[key] = val self.new_items_found = True - elif dependant_sle.voucher_type == "Stock Entry" and is_transfer_stock_entry( - dependant_sle.voucher_no + elif ( + dependant_sle.actual_qty > 0 + and dependant_sle.voucher_type == "Stock Entry" + and is_transfer_stock_entry(dependant_sle.voucher_no) ): if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"): return @@ -1156,7 +1187,11 @@ class update_entries_after: def get_dynamic_incoming_outgoing_rate(self, sle): # Get updated incoming/outgoing rate from transaction - if sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle): + if ( + sle.recalculate_rate + or self.has_landed_cost_based_on_pi(sle) + or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no)) + ): rate = self.get_incoming_outgoing_rate_from_transaction(sle) if flt(sle.actual_qty) >= 0: @@ -1813,6 +1848,9 @@ def get_stock_ledger_entries( if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s" + if previous_sle.get("filter_voucher_type"): + conditions += " and voucher_type = %(filter_voucher_type)s" + if extra_cond: conditions += f"{extra_cond}" @@ -1908,8 +1946,7 @@ def get_valuation_rate( & (table.warehouse == warehouse) & (table.batch_no == batch_no) & (table.is_cancelled == 0) - & (table.voucher_no != voucher_no) - & (table.voucher_type != voucher_type) + & ((table.voucher_no != voucher_no) | (table.voucher_type != voucher_type)) ) ) @@ -2454,3 +2491,8 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj): incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(row.batch_no))) return incoming_rate + + +@frappe.request_cache +def is_repack_entry(stock_entry_id): + return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack" diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py index 51447e0591b..1dc45b426b7 100644 --- a/erpnext/utilities/bulk_transaction.py +++ b/erpnext/utilities/bulk_transaction.py @@ -16,9 +16,30 @@ def transaction_processing(data, from_doctype, to_doctype): else: deserialized_data = data + skipped_records = [d for d in deserialized_data if d.get("status") in ("On Hold", "Closed")] + + deserialized_data = [d for d in deserialized_data if d.get("status") not in ("On Hold", "Closed")] + length_of_data = len(deserialized_data) - frappe.msgprint(_("Started a background job to create {1} {0}").format(to_doctype, length_of_data)) + skipped_msg = "" + + if skipped_records: + skipped_msg = _("{0} creation for the following records will be skipped.").format(to_doctype) + + skipped_msg += ( + "

" + ) + + if not length_of_data: + frappe.msgprint(skipped_msg) + return + + frappe.msgprint( + _("Started a background job to create {1} {0}. {2}").format(to_doctype, length_of_data, skipped_msg) + ) frappe.enqueue( job, deserialized_data=deserialized_data,