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