diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py
index f81afbd1297..99b4518b695 100644
--- a/erpnext/accounts/doctype/account/account.py
+++ b/erpnext/accounts/doctype/account/account.py
@@ -110,6 +110,7 @@ class Account(NestedSet):
self.validate_parent_child_account_type()
self.validate_root_details()
self.validate_account_number()
+ self.validate_disabled()
self.validate_group_or_ledger()
self.set_root_and_report_type()
self.validate_mandatory()
@@ -254,6 +255,14 @@ class Account(NestedSet):
self.create_account_for_child_company(parent_acc_name_map, descendants, parent_acc_name)
+ def validate_disabled(self):
+ doc_before_save = self.get_doc_before_save()
+ if not doc_before_save or cint(doc_before_save.disabled) == cint(self.disabled):
+ return
+
+ if cint(self.disabled):
+ self.validate_default_accounts_in_company()
+
def validate_group_or_ledger(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save or cint(doc_before_save.is_group) == cint(self.is_group):
@@ -264,9 +273,32 @@ class Account(NestedSet):
elif cint(self.is_group):
if self.account_type and not self.flags.exclude_account_type_check:
throw(_("Cannot covert to Group because Account Type is selected."))
+ self.validate_default_accounts_in_company()
elif self.check_if_child_exists():
throw(_("Account with child nodes cannot be set as ledger"))
+ def validate_default_accounts_in_company(self):
+ default_account_fields = get_company_default_account_fields()
+
+ company_default_accounts = frappe.db.get_value(
+ "Company", self.company, list(default_account_fields.keys()), as_dict=1
+ )
+
+ msg = _("Account {0} cannot be disabled as it is already set as {1} for {2}.")
+
+ if not self.disabled:
+ msg = _("Account {0} cannot be converted to Group as it is already set as {1} for {2}.")
+
+ for d in default_account_fields:
+ if company_default_accounts.get(d) == self.name:
+ throw(
+ msg.format(
+ frappe.bold(self.name),
+ frappe.bold(default_account_fields.get(d)),
+ frappe.bold(self.company),
+ )
+ )
+
def validate_frozen_accounts_modifier(self):
doc_before_save = self.get_doc_before_save()
if not doc_before_save or doc_before_save.freeze_account == self.freeze_account:
@@ -627,3 +659,27 @@ def _ensure_idle_system():
).format(pretty_date(last_gl_update)),
title=_("System In Use"),
)
+
+
+def get_company_default_account_fields():
+ return {
+ "default_bank_account": "Default Bank Account",
+ "default_cash_account": "Default Cash Account",
+ "default_receivable_account": "Default Receivable Account",
+ "default_payable_account": "Default Payable Account",
+ "default_expense_account": "Default Expense Account",
+ "default_income_account": "Default Income Account",
+ "stock_received_but_not_billed": "Stock Received But Not Billed Account",
+ "stock_adjustment_account": "Stock Adjustment Account",
+ "write_off_account": "Write Off Account",
+ "default_discount_account": "Default Payment Discount Account",
+ "unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
+ "exchange_gain_loss_account": "Exchange Gain / Loss Account",
+ "unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
+ "round_off_account": "Round Off Account",
+ "default_deferred_revenue_account": "Default Deferred Revenue Account",
+ "default_deferred_expense_account": "Default Deferred Expense Account",
+ "accumulated_depreciation_account": "Accumulated Depreciation Account",
+ "depreciation_expense_account": "Depreciation Expense Account",
+ "disposal_account": "Gain/Loss Account on Asset Disposal",
+ }
diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
index 060c4b5edaa..c34e0f9099c 100644
--- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
+++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js
@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
- frm.set_query("bank", function () {
- return {
- filters: {
- company: frm.doc.company,
- },
- };
- });
frm.set_query("bank_account", function () {
return {
filters: {
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 729155cec8d..1fb93846735 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -664,7 +664,16 @@ class ReceivablePayableReport:
invoiced = d.base_payment_amount
paid_amount = d.base_paid_amount
- if company_currency == d.party_account_currency or self.filters.get("in_party_currency"):
+ in_party_currency = self.filters.get("in_party_currency")
+ # company, billing, and party account currencies are the same
+ if company_currency == d.currency and company_currency == d.party_account_currency:
+ in_party_currency = False
+
+ # When filtered by party currency and the billing currency not matches the party account currency
+ if in_party_currency and d.currency != d.party_account_currency:
+ in_party_currency = False
+
+ if in_party_currency:
invoiced = d.payment_amount
paid_amount = d.paid_amount
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 44fee120d8b..19f51dc7a03 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -199,6 +199,81 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
row = report[1]
self.assertTrue(len(row) == 0)
+ @change_settings(
+ "Accounts Settings",
+ {"allow_multi_currency_invoices_against_single_party_account": 1},
+ )
+ def test_allow_multi_currency_invoices_against_single_party_account(self):
+ filters = {
+ "company": self.company,
+ "based_on_payment_terms": 1,
+ "report_date": today(),
+ "range": "30, 60, 90, 120",
+ "show_remarks": True,
+ "in_party_currency": 1,
+ }
+
+ # CASE 1: Company currency and party account currency are the same
+ si = self.create_sales_invoice(qty=1, no_payment_schedule=True, do_not_submit=True)
+ si.currency = "USD"
+ si.conversion_rate = 80
+ si.save().submit()
+
+ filters.update(
+ {
+ "party_type": "Customer",
+ "party": [self.customer],
+ }
+ )
+ report = execute(filters)
+ row = report[1][0]
+
+ expected_data = [8000, 8000, "No Remarks"] # Data in company currency
+
+ self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
+
+ # CASE 2: Transaction currency and party account currency are the same
+ self.create_customer(
+ "USD Customer", currency="USD", default_account=self.debtors_usd, company=self.company
+ )
+ si = create_sales_invoice(
+ item=self.item,
+ company=self.company,
+ customer=self.customer,
+ debit_to=self.debtors_usd,
+ posting_date=today(),
+ parent_cost_center=self.cost_center,
+ cost_center=self.cost_center,
+ rate=100,
+ currency="USD",
+ conversion_rate=80,
+ price_list_rate=100,
+ do_not_save=1,
+ )
+ si.save().submit()
+
+ filters.update(
+ {
+ "party_type": "Customer",
+ "party": [self.customer],
+ }
+ )
+ report = execute(filters)
+ row = report[1][0]
+
+ expected_data = [100, 100, "No Remarks"] # Data in Part Account Currency
+
+ self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
+
+ # View in Company currency
+ filters.pop("in_party_currency")
+ report = execute(filters)
+ row = report[1][0]
+
+ expected_data = [8000, 8000, "No Remarks"] # Data in Company Currency
+
+ self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
+
def test_accounts_receivable_with_partial_payment(self):
filters = {
"company": self.company,
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 0ddb95fff2f..af20179d743 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -85,6 +85,12 @@ frappe.query_reports["Gross Profit"] = {
});
},
},
+ {
+ fieldname: "include_returned_invoices",
+ label: __("Include Returned Invoices (Stand-alone)"),
+ fieldtype: "Check",
+ default: 1,
+ },
],
tree: true,
name_field: "parent",
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index baf2da6ceea..d2fe570fa3b 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -859,7 +859,10 @@ class GrossProfitGenerator:
if self.filters.to_date:
conditions += " and posting_date <= %(to_date)s"
- conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
+ if self.filters.include_returned_invoices:
+ conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
+ else:
+ conditions += " and is_return = 0"
if self.filters.item_group:
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index 88a614074e0..d92c16ab440 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -442,7 +442,11 @@ class TestGrossProfit(FrappeTestCase):
sinv = sinv.save().submit()
filters = frappe._dict(
- company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
+ company=self.company,
+ from_date=nowdate(),
+ to_date=nowdate(),
+ group_by="Invoice",
+ include_returned_invoices=1,
)
columns, data = execute(filters=filters)
diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py
index fbd0c76a229..3cad657553e 100644
--- a/erpnext/accounts/test/accounts_mixin.py
+++ b/erpnext/accounts/test/accounts_mixin.py
@@ -5,7 +5,9 @@ from erpnext.stock.doctype.item.test_item import create_item
class AccountsTestMixin:
- def create_customer(self, customer_name="_Test Customer", currency=None):
+ def create_customer(
+ self, customer_name="_Test Customer", currency=None, default_account=None, company=None
+ ):
if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
@@ -13,9 +15,28 @@ class AccountsTestMixin:
if currency:
customer.default_currency = currency
+ if company and default_account:
+ customer.append(
+ "accounts",
+ {
+ "company": company,
+ "account": default_account,
+ },
+ )
customer.save()
self.customer = customer.name
else:
+ if company and default_account:
+ customer = frappe.get_doc("Customer", customer_name)
+ customer.accounts = []
+ customer.append(
+ "accounts",
+ {
+ "company": company,
+ "account": default_account,
+ },
+ )
+ customer.save()
self.customer = customer_name
def create_supplier(self, supplier_name="_Test Supplier", currency=None):
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 929a60d0e6d..6fef1b21825 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -939,7 +939,7 @@ def make_post_gl_entry():
assets = frappe.db.sql_list(
""" select name from `tabAsset`
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
- and available_for_use_date = %s""",
+ and available_for_use_date = %s and docstatus = 1""",
(asset_category.name, nowdate()),
)
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index b134e79a137..6410c28ed19 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -2026,7 +2026,7 @@ class AccountsController(TransactionBase):
discount_amount * self.get("conversion_rate"),
item.precision("discount_amount"),
),
- dr_or_cr + "_in_account_currency": flt(
+ dr_or_cr + "_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
@@ -2047,7 +2047,7 @@ class AccountsController(TransactionBase):
discount_amount * self.get("conversion_rate"),
item.precision("discount_amount"),
),
- rev_dr_cr + "_in_account_currency": flt(
+ rev_dr_cr + "_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
@@ -3661,8 +3661,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item"
return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
+ def is_allowed_zero_qty():
+ if parent_doctype == "Sales Order":
+ return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False
+ elif parent_doctype == "Purchase Order":
+ return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
+ return False
+
def validate_quantity(child_item, new_data):
- if not flt(new_data.get("qty")):
+ if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
frappe.throw(
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
@@ -3798,6 +3805,11 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
conv_fac_precision = child_item.precision("conversion_factor") or 2
qty_precision = child_item.precision("qty") or 2
+ prev_rate, new_rate = flt(child_item.get("rate")), flt(d.get("rate"))
+ rate_unchanged = prev_rate == new_rate
+ if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty():
+ frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
+
# Amount cannot be lesser than billed amount, except for negative amounts
row_rate = flt(d.get("rate"), rate_precision)
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index 5c0f78ac986..29f7d5810b3 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -480,6 +480,13 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
target_doc.subcontracting_order_item = source_doc.subcontracting_order_item
target_doc.rejected_warehouse = source_doc.rejected_warehouse
target_doc.subcontracting_receipt_item = source_doc.name
+ if return_against_rejected_qty:
+ target_doc.qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get("qty") or 0))
+ target_doc.rejected_qty = 0.0
+ target_doc.rejected_warehouse = ""
+ target_doc.warehouse = source_doc.rejected_warehouse
+ target_doc.received_qty = target_doc.qty
+ target_doc.return_qty_from_rejected_warehouse = 1
else:
target_doc.purchase_order = source_doc.purchase_order
target_doc.purchase_order_item = source_doc.purchase_order_item
diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json
index 5be71cdf9d2..175c5818c43 100644
--- a/erpnext/manufacturing/doctype/bom/bom.json
+++ b/erpnext/manufacturing/doctype/bom/bom.json
@@ -279,6 +279,7 @@
"oldfieldtype": "Section Break"
},
{
+ "allow_bulk_edit": 1,
"fieldname": "items",
"fieldtype": "Table",
"label": "Items",
@@ -590,6 +591,7 @@
},
{
"default": "0",
+ "depends_on": "eval:doc.track_semi_finished_goods === 0",
"fieldname": "fg_based_operating_cost",
"fieldtype": "Check",
"label": "Finished Goods based Operating Cost"
@@ -638,7 +640,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
- "modified": "2025-06-16 16:13:22.497695",
+ "modified": "2025-10-29 17:43:12.966753",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM",
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 047b39df54f..22264fb0e92 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -158,6 +158,7 @@ class BOM(WebsiteGenerator):
show_operations: DF.Check
thumbnail: DF.Data | None
total_cost: DF.Currency
+ track_semi_finished_goods: DF.Check
transfer_material_against: DF.Literal["", "Work Order", "Job Card"]
uom: DF.Link | None
web_long_description: DF.TextEditor | None
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index c3b0bb10fe6..9d3c646598b 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -408,7 +408,7 @@ frappe.ui.form.on("Job Card", {
function updateStopwatch(increment) {
var hours = Math.floor(increment / 3600);
var minutes = Math.floor((increment - hours * 3600) / 60);
- var seconds = increment - hours * 3600 - minutes * 60;
+ var seconds = Math.floor(increment - hours * 3600 - minutes * 60);
$(section)
.find(".hours")
@@ -431,7 +431,7 @@ frappe.ui.form.on("Job Card", {
frm.dashboard.refresh();
const timer = `
00
:
00
@@ -441,20 +441,34 @@ frappe.ui.form.on("Job Card", {
var section = frm.toolbar.page.add_inner_message(timer);
- let currentIncrement = frm.doc.current_time || 0;
+ let currentIncrement = frm.events.get_current_time(frm);
if (frm.doc.started_time || frm.doc.current_time) {
if (frm.doc.status == "On Hold") {
updateStopwatch(currentIncrement);
} else {
- currentIncrement += moment(frappe.datetime.now_datetime()).diff(
- moment(frm.doc.started_time),
- "seconds"
- );
initialiseTimer();
}
}
},
+ get_current_time(frm) {
+ let current_time = 0;
+
+ frm.doc.time_logs.forEach((d) => {
+ if (d.to_time) {
+ if (d.time_in_mins) {
+ current_time += flt(d.time_in_mins, 2) * 60;
+ } else {
+ current_time += get_seconds_diff(d.to_time, d.from_time);
+ }
+ } else {
+ current_time += get_seconds_diff(frappe.datetime.now_datetime(), d.from_time);
+ }
+ });
+
+ return current_time;
+ },
+
hide_timer: function (frm) {
frm.toolbar.page.inner_toolbar.find(".stopwatch").remove();
},
@@ -519,3 +533,7 @@ frappe.ui.form.on("Job Card Time Log", {
frm.set_value("started_time", "");
},
});
+
+function get_seconds_diff(d1, d2) {
+ return moment(d1).diff(d2, "seconds");
+}
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index e0e68c37451..8c0be8042ac 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -17,17 +17,7 @@ erpnext.buying = {
this.setup_queries(doc, cdt, cdn);
super.onload();
- if (["Purchase Order", "Purchase Receipt", "Purchase Invoice"].includes(this.frm.doctype)) {
- this.frm.set_query("supplier", function () {
- return {
- filters: {
- is_transporter: 0,
- },
- };
- });
- }
-
- this.frm.set_query("shipping_rule", function () {
+ this.frm.set_query('shipping_rule', function() {
return {
filters: {
"shipping_rule_type": "Buying"
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index eb1e33acafe..f9978099ed5 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -181,11 +181,35 @@ class Company(NestedSet):
["Stock Received But Not Billed Account", "stock_received_but_not_billed"],
["Stock Adjustment Account", "stock_adjustment_account"],
["Expense Included In Valuation Account", "expenses_included_in_valuation"],
+ ["Write Off Account", "write_off_account"],
+ ["Default Payment Discount Account", "default_discount_account"],
+ ["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
+ ["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
+ ["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"],
+ ["Round Off Account", "round_off_account"],
+ ["Default Deferred Revenue Account", "default_deferred_revenue_account"],
+ ["Default Deferred Expense Account", "default_deferred_expense_account"],
+ ["Accumulated Depreciation Account", "accumulated_depreciation_account"],
+ ["Depreciation Expense Account", "depreciation_expense_account"],
+ ["Gain/Loss Account on Asset Disposal", "disposal_account"],
]
for account in accounts:
if self.get(account[1]):
- for_company = frappe.db.get_value("Account", self.get(account[1]), "company")
+ for_company, is_group, disabled = frappe.db.get_value(
+ "Account", self.get(account[1]), ["company", "is_group", "disabled"]
+ )
+
+ if disabled:
+ frappe.throw(_("Account {0} is disabled.").format(frappe.bold(self.get(account[1]))))
+
+ if is_group:
+ frappe.throw(
+ _("{0}: {1} is a group account.").format(
+ frappe.bold(account[0]), frappe.bold(self.get(account[1]))
+ )
+ )
+
if for_company != self.name:
frappe.throw(
_("Account {0} does not belong to company: {1}").format(
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index c0a6c4d0f4a..383421f3c67 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -4287,6 +4287,67 @@ class TestPurchaseReceipt(FrappeTestCase):
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
+ @change_settings(
+ "Buying Settings",
+ {"bill_for_rejected_quantity_in_purchase_invoice": 1, "set_valuation_rate_for_rejected_materials": 1},
+ )
+ def test_valuation_rate_for_rejected_materials_with_serial_no(self):
+ item = make_item(
+ "Test Serial Item with Rej Material Valuation",
+ {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SNU-TSIRMV-.#####"},
+ )
+ company = "_Test Company with perpetual inventory"
+
+ warehouse = create_warehouse(
+ "_Test In-ward Warehouse",
+ company="_Test Company with perpetual inventory",
+ )
+
+ rej_warehouse = create_warehouse(
+ "_Test Warehouse - Rejected Material",
+ company="_Test Company with perpetual inventory",
+ )
+
+ pr = make_purchase_receipt(
+ item_code=item.name,
+ qty=10,
+ rate=100,
+ company=company,
+ warehouse=warehouse,
+ rejected_qty=5,
+ rejected_warehouse=rej_warehouse,
+ )
+
+ stock_received_but_not_billed_account = frappe.get_value(
+ "Company",
+ company,
+ "stock_received_but_not_billed",
+ )
+
+ rejected_item_cost = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": pr.name,
+ "warehouse": rej_warehouse,
+ },
+ "stock_value_difference",
+ )
+
+ self.assertEqual(rejected_item_cost, 500)
+
+ srbnb_cost = frappe.db.get_value(
+ "GL Entry",
+ {
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": pr.name,
+ "account": stock_received_but_not_billed_account,
+ },
+ "credit",
+ )
+
+ self.assertEqual(srbnb_cost, 1500)
+
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
company = "_Test Company with perpetual inventory"
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 0655cd2cfd7..1992b5dc49f 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
@@ -667,8 +667,12 @@ class SerialandBatchBundle(Document):
if batches and valuation_method == "FIFO":
stock_queue = parse_json(prev_sle.stock_queue)
+ set_valuation_rate_for_rejected_materials = frappe.db.get_single_value(
+ "Buying Settings", "set_valuation_rate_for_rejected_materials"
+ )
+
for d in self.entries:
- if self.is_rejected:
+ if self.is_rejected and not set_valuation_rate_for_rejected_materials:
rate = 0.0
elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference:
continue
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index f8a9160aca8..63d2e80888c 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -241,6 +241,8 @@ class StockEntry(StockController):
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
+ self.validate_same_source_target_warehouse_during_material_transfer()
+
def on_submit(self):
self.validate_closed_subcontracting_order()
self.make_bundle_using_old_serial_batch_fields()
@@ -796,6 +798,53 @@ class StockEntry(StockController):
title=_("Missing Item"),
)
+ def validate_same_source_target_warehouse_during_material_transfer(self):
+ """
+ Validate Material Transfer entries where source and target warehouses are identical.
+
+ For Material Transfer purpose, if an item has the same source and target warehouse,
+ require that at least one inventory dimension (if configured) differs between source
+ and target to ensure a meaningful transfer is occurring.
+
+ Raises:
+ frappe.ValidationError: If warehouses are same and no inventory dimensions differ
+ """
+ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
+
+ inventory_dimensions = get_inventory_dimensions()
+ if self.purpose == "Material Transfer":
+ for item in self.items:
+ if cstr(item.s_warehouse) == cstr(item.t_warehouse):
+ if not inventory_dimensions:
+ frappe.throw(
+ _(
+ "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
+ ).format(item.idx),
+ title=_("Invalid Source and Target Warehouse"),
+ )
+ else:
+ difference_found = False
+ for dimension in inventory_dimensions:
+ fieldname = (
+ dimension.source_fieldname
+ if dimension.source_fieldname.startswith("to_")
+ else f"to_{dimension.source_fieldname}"
+ )
+ if (
+ item.get(dimension.source_fieldname)
+ and item.get(fieldname)
+ and item.get(dimension.source_fieldname) != item.get(fieldname)
+ ):
+ difference_found = True
+ break
+ if not difference_found:
+ frappe.throw(
+ _(
+ "Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
+ ).format(item.idx),
+ title=_("Invalid Source and Target Warehouse"),
+ )
+
def get_matched_items(self, item_code):
for row in self.items:
if row.item_code == item_code or row.original_item == item_code:
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 2df4b257aa5..bc54f88687e 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1914,7 +1914,7 @@ def get_valuation_rate(
)
last_valuation_rate = query.run()
- if last_valuation_rate:
+ if last_valuation_rate and last_valuation_rate[0][0] is not None:
return flt(last_valuation_rate[0][0])
# Get moving average rate of a specific batch number
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
index fee1cac2542..4e502793068 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
@@ -82,10 +82,53 @@ frappe.ui.form.on("Subcontracting Receipt", {
frm.add_custom_button(
__("Subcontract Return"),
() => {
- frappe.model.open_mapped_doc({
- method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
- frm: frm,
+ const make_standard_return = () => {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
+ frm: frm,
+ });
+ };
+
+ let has_rejected_items = frm.doc.items.filter((item) => {
+ if (item.rejected_qty > 0) {
+ return true;
+ }
});
+
+ if (has_rejected_items && has_rejected_items.length > 0) {
+ frappe.prompt(
+ [
+ {
+ label: __("Return Qty from Rejected Warehouse"),
+ fieldtype: "Check",
+ fieldname: "return_for_rejected_warehouse",
+ default: 1,
+ },
+ ],
+ function (values) {
+ if (values.return_for_rejected_warehouse) {
+ frappe.call({
+ method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return_against_rejected_warehouse",
+ args: {
+ source_name: frm.doc.name,
+ },
+ callback: function (r) {
+ if (r.message) {
+ frappe.model.sync(r.message);
+ frappe.set_route("Form", r.message.doctype, r.message.name);
+ }
+ },
+ });
+ } else {
+ make_standard_return();
+ }
+ },
+ __("Return Qty"),
+ __("Make Return Entry")
+ );
+ } else {
+ make_standard_return();
+ }
},
__("Create")
);
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
index 99a6abc8b91..d2ceb90ad52 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
@@ -774,6 +774,13 @@ class SubcontractingReceipt(SubcontractingController):
make_purchase_receipt(self, save=True, notify=True)
+@frappe.whitelist()
+def make_subcontract_return_against_rejected_warehouse(source_name):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True)
+
+
@frappe.whitelist()
def make_subcontract_return(source_name, target_doc=None):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
index d5e30bd9368..b9d062af5b2 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
@@ -1196,6 +1196,136 @@ class TestSubcontractingReceipt(FrappeTestCase):
scr.cancel()
self.assertTrue(scr.docstatus == 2)
+ def test_subcontract_return_from_rejected_warehouse(self):
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+ from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
+ make_subcontract_return_against_rejected_warehouse,
+ )
+
+ # Create subcontracted item
+ fg_item = make_item(
+ "_Test Subcontract Item Return from Rejected Warehouse",
+ properties={
+ "is_stock_item": 1,
+ "is_sub_contracted_item": 1,
+ },
+ ).name
+
+ # Create service item
+ service_item = make_item(
+ "_Test Service Item Return from Rejected Warehouse", properties={"is_stock_item": 0}
+ ).name
+
+ # Create BOM for the subcontracted item with required raw materials
+ rm_item1 = make_item(
+ "_Test RM Item 1 Return from Rejected Warehouse", properties={"is_stock_item": 1}
+ ).name
+
+ rm_item2 = make_item(
+ "_Test RM Item 2 Return from Rejected Warehouse", properties={"is_stock_item": 1}
+ ).name
+
+ make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2])
+
+ # Create warehouses
+ rejected_warehouse = create_warehouse("_Test Subcontract Rejected Warehouse Return Qty Warehouse")
+
+ # Create service items for subcontracting order
+ service_items = [
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": service_item,
+ "qty": 10,
+ "rate": 100,
+ "fg_item": fg_item,
+ "fg_item_qty": 10,
+ },
+ ]
+
+ # Create Subcontracting Order
+ sco = get_subcontracting_order(service_items=service_items)
+
+ # Stock raw materials
+ make_stock_entry(item_code=rm_item1, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
+ make_stock_entry(item_code=rm_item2, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
+
+ # Transfer raw materials
+ rm_items = get_rm_items(sco.supplied_items)
+ itemwise_details = make_stock_in_entry(rm_items=rm_items)
+ make_stock_transfer_entry(
+ sco_no=sco.name,
+ rm_items=rm_items,
+ itemwise_details=copy.deepcopy(itemwise_details),
+ )
+
+ # Step 1: Create Subcontracting Receipt with rejected quantity
+ sr = make_subcontracting_receipt(sco.name)
+ sr.items[0].qty = 8 # Accepted quantity
+ sr.items[0].rejected_qty = 2
+ sr.items[0].rejected_warehouse = rejected_warehouse
+ sr.save()
+ sr.submit()
+
+ # Verify initial state
+ sr.reload()
+ self.assertEqual(sr.items[0].qty, 8)
+ self.assertEqual(sr.items[0].rejected_qty, 2)
+ self.assertEqual(sr.items[0].rejected_warehouse, rejected_warehouse)
+
+ # Step 2: Create Subcontract Return from Rejected Warehouse
+ sr_return = make_subcontract_return_against_rejected_warehouse(sr.name)
+
+ # Verify the return document properties
+ self.assertEqual(sr_return.doctype, "Subcontracting Receipt")
+ self.assertEqual(sr_return.is_return, 1)
+ self.assertEqual(sr_return.return_against, sr.name)
+
+ # Verify item details in return document
+ self.assertEqual(len(sr_return.items), 1)
+ self.assertEqual(sr_return.items[0].item_code, fg_item)
+ self.assertEqual(sr_return.items[0].warehouse, rejected_warehouse)
+ self.assertEqual(sr_return.items[0].qty, -2.0) # Negative for return
+ self.assertEqual(sr_return.items[0].rejected_qty, 0.0)
+ self.assertEqual(sr_return.items[0].rejected_warehouse, "")
+
+ # Check specific fields that should be set for subcontracting returns
+ self.assertEqual(sr_return.items[0].subcontracting_order, sco.name)
+ self.assertEqual(sr_return.items[0].subcontracting_order_item, sr.items[0].subcontracting_order_item)
+ self.assertEqual(sr_return.items[0].return_qty_from_rejected_warehouse, 1)
+
+ # For returns from rejected warehouse, supplied_items might be empty initially
+ # They might get populated when the document is saved/submitted
+ # Or they might not be needed since we're returning finished goods
+
+ # Save and submit the return
+ sr_return.save()
+ sr_return.submit()
+
+ # Verify final state
+ sr_return.reload()
+ self.assertEqual(sr_return.docstatus, 1)
+ self.assertEqual(sr_return.status, "Return")
+
+ # Verify stock ledger entries for the return
+ sle = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={
+ "voucher_type": "Subcontracting Receipt",
+ "voucher_no": sr_return.name,
+ "warehouse": rejected_warehouse,
+ },
+ fields=["item_code", "actual_qty", "warehouse"],
+ )
+
+ self.assertEqual(len(sle), 1)
+ self.assertEqual(sle[0].item_code, fg_item)
+ self.assertEqual(sle[0].actual_qty, -2.0) # Outward entry from rejected warehouse
+ self.assertEqual(sle[0].warehouse, rejected_warehouse)
+
+ # Verify that the original document's rejected quantity is not affected
+ sr.reload()
+ self.assertEqual(sr.items[0].rejected_qty, 2) # Should remain the same
+
@change_settings("Buying Settings", {"auto_create_purchase_receipt": 1})
def test_auto_create_purchase_receipt(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order