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 += (
+ "