From edc8daae21c69a365957edb173f28f874f7ff10e Mon Sep 17 00:00:00 2001 From: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:26:01 +0530 Subject: [PATCH 01/60] fix(subscription): don't reactivate a cancelled subscription (backport #57774) (#57780) * fix(subscription): don't reactivate a cancelled subscription Backport of #57774 to version-15-hotfix. set_subscription_status() unconditionally set status to Active once there was no outstanding invoice, with no check for whether the subscription had been intentionally cancelled. process()'s cancel_at_period_end check compared posting_date against getdate(self.end_date), and getdate(None) returns today, so an empty end_date was silently treated as "cancel now" on every reprocess. version-15-hotfix does not yet have the Payment Entry -> invoice -> refresh_subscription_status() hook chain that #57761 reports (that was added later), and the scheduler already excludes Cancelled subscriptions, so the exact automatic repro in the issue does not apply here. The underlying status logic is defective the same way though, and reachable directly (e.g. any caller invoking the whitelisted process()/set_subscription_status() on a cancelled subscription), so fixing it here too rather than leaving the same class of bug in place. Related to #57761 * fix(test): avoid tripping the unrelated period-end cancel clause The test backdated start_date to 2018-01-01, so by the time process() ran, current_invoice_end was long past. That made the *other* half of the cancel_at_period_end OR-condition (period end reached) true on its own, so process() tried to cancel an already-cancelled subscription and hit cancel_subscription()'s "already cancelled" guard - unrelated to the empty end_date bug being tested. Use nowdate() as start_date so current_invoice_end sits safely in the future, isolating the assertion to the empty end_date guard. Also drop the fragile intermediate "Unpaid" status assertion, which wasn't part of what this test verifies. * fix(subscription): don't let period rollover defeat cancel_at_period_end process() can advance current_invoice_end to the next period (via update_subscription_period(), when generating the current period's invoice) before the cancel_at_period_end check further down runs. For a subscription with no end_date, that check now compared posting_date against the already-rolled-forward current_invoice_end, which is always in the future, so cancel_at_period_end was silently never honored. Snapshot current_invoice_end before any rollover and use that in the check instead, so it still targets the period that just ended. Fixes the P1 flagged by Greptile review on this PR. --------- Co-authored-by: test --- .../doctype/subscription/subscription.py | 12 ++++- .../doctype/subscription/test_subscription.py | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index 3665bf34bf2..e7dc9b078fc 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -222,6 +222,9 @@ class Subscription(Document): """ Sets the status of the `Subscription` """ + if self.status == "Cancelled": + return + if self.is_trialling(): self.status = "Trialling" elif self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date): @@ -558,6 +561,11 @@ class Subscription(Document): 1. `process_for_active` 2. `process_for_past_due` """ + # Snapshot before update_subscription_period() below can roll this forward, + # so the cancel_at_period_end check further down still targets the period + # that just ended, not the next one. + current_period_end = self.current_invoice_end + if not self.is_current_invoice_generated( self.current_invoice_start, self.current_invoice_end ) and self.can_generate_new_invoice(posting_date): @@ -567,8 +575,8 @@ class Subscription(Document): self.update_subscription_period() if self.cancel_at_period_end and ( - getdate(posting_date) >= getdate(self.current_invoice_end) - or getdate(posting_date) >= getdate(self.end_date) + getdate(posting_date) >= getdate(current_period_end) + or (self.end_date and getdate(posting_date) >= getdate(self.end_date)) ): self.cancel_subscription() diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 41ada4c804f..5cf52fa1eb4 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -280,6 +280,59 @@ class TestSubscription(FrappeTestCase): settings.cancel_after_grace = default_grace_period_action settings.save() + def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self): + # https://github.com/frappe/erpnext/issues/57761 + subscription = create_subscription( + start_date=nowdate(), generate_invoice_at="Beginning of the current subscription period" + ) + subscription.process(posting_date=nowdate()) # generate first invoice + invoice = subscription.get_current_invoice() + self.assertIsNotNone(invoice) + + invoice.db_set("outstanding_amount", 0) + invoice.db_set("status", "Paid") + + subscription.cancel_subscription() + self.assertEqual(subscription.status, "Cancelled") + cancelation_date = getdate(subscription.cancelation_date) + + subscription.set_subscription_status() + self.assertEqual(subscription.status, "Cancelled") + self.assertEqual(getdate(subscription.cancelation_date), cancelation_date) + + subscription.cancel_at_period_end = 1 + subscription.end_date = None + invoice_count = len(subscription.invoices) + subscription.process() + self.assertEqual(subscription.status, "Cancelled") + self.assertEqual(len(subscription.invoices), invoice_count) + + def test_subscription_cancels_at_period_end_without_end_date(self): + # https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls + # current_invoice_end forward to the next period before this check runs, so + # with no end_date to fall back on, cancel_at_period_end must compare + # against the period that just ended, not the (already advanced) next one. + create_plan( + plan_name="_Test plan name 11", + cost=80, + currency="INR", + billing_interval="Day", + billing_interval_count=3, + ) + subscription = create_subscription( + start_date=nowdate(), + generate_invoice_at="End of the current subscription period", + plans=[{"plan": "_Test plan name 11", "qty": 1}], + ) + subscription.cancel_at_period_end = 1 + self.assertEqual(len(subscription.invoices), 0) + period_end = subscription.current_invoice_end + + subscription.process(posting_date=period_end) + + self.assertEqual(subscription.status, "Cancelled") + self.assertEqual(len(subscription.invoices), 1) + def test_subscription_restart_and_process(self): settings = frappe.get_single("Subscription Settings") default_grace_period_action = settings.cancel_after_grace From 69c00b33627e3910d8315fc4a38b8b6c62d715ba Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:35:28 +0000 Subject: [PATCH 02/60] fix(accounts receivable): made territory field multi select (backport #57322) (#57808) fix(accounts receivable): made territory field multi select (#57322) (cherry picked from commit 1029cd988adef74a1830cdaaa816c163645ed259) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- .../accounts_payable/accounts_payable.js | 5 +- .../accounts_payable/test_accounts_payable.py | 30 +++++++++++ .../accounts_payable_summary.js | 5 +- .../accounts_receivable.js | 5 +- .../accounts_receivable.py | 53 +++++++++---------- .../test_accounts_receivable.py | 32 +++++++++++ .../accounts_receivable_summary.js | 5 +- 7 files changed, 104 insertions(+), 31 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index 4da827f1a81..8de9d60a8fd 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -117,8 +117,11 @@ frappe.query_reports["Accounts Payable"] = { { fieldname: "supplier_group", label: __("Supplier Group"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Supplier Group", + get_data: function (txt) { + return frappe.db.get_link_options("Supplier Group", txt); + }, hidden: 1, }, { diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index 5a4e11b5291..03bc55684d2 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -121,6 +121,36 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 2) self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + def test_supplier_group_filter(self): + pi = self.create_purchase_invoice() + supplier_group = frappe.db.get_value("Supplier", self.supplier, "supplier_group") + other_group = frappe.get_doc( + doctype="Supplier Group", + supplier_group_name="_Test Supplier Group AP", + parent_supplier_group="All Supplier Groups", + ).insert() + + filters = { + "company": self.company, + "party_type": "Supplier", + "report_date": today(), + "range": "30, 60, 90, 120", + "supplier_group": supplier_group, + } + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": [other_group.name]}) + self.assertEqual(len(execute(filters)[1]), 0) + + filters.update({"supplier_group": [supplier_group, other_group.name]}) + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": ["All Supplier Groups"]}) + self.assertIn(pi.name, [row.voucher_no for row in execute(filters)[1]]) + + filters.update({"supplier_group": ["_Test Supplier Group Mars"]}) + self.assertRaises(frappe.ValidationError, execute, filters) + def test_project_filter(self): project = frappe.get_doc( {"doctype": "Project", "project_name": "_Test AP Project", "company": self.company} diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index 0b3bc077698..a5a42cb963b 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -100,8 +100,11 @@ frappe.query_reports["Accounts Payable Summary"] = { { fieldname: "supplier_group", label: __("Supplier Group"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Supplier Group", + get_data: function (txt) { + return frappe.db.get_link_options("Supplier Group", txt); + }, }, { fieldname: "based_on_payment_terms", diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index e0444a0af1e..20170d7eddc 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -140,8 +140,11 @@ frappe.query_reports["Accounts Receivable"] = { { fieldname: "territory", label: __("Territory"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Territory", + get_data: function (txt) { + return frappe.db.get_link_options("Territory", txt); + }, }, { fieldname: "group_by_party", diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 5405bafab07..ef8c9a193c4 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -1013,7 +1013,13 @@ class ReceivablePayableReport: self.qb_selection_filter.append(self.ple.party.isin(customers)) if self.filters.get("territory"): - self.get_hierarchical_filters("Territory", "territory") + territories = get_nested_set_children("Territory", self.filters.territory) + customers = ( + qb.from_(self.customer) + .select(self.customer.name) + .where(self.customer["territory"].isin(territories)) + ) + self.qb_selection_filter.append(self.ple.party.isin(customers)) if self.filters.get("payment_terms_template"): customer_ptt = self.ple.party.isin( @@ -1034,11 +1040,10 @@ class ReceivablePayableReport: def add_supplier_filters(self): supplier = qb.DocType("Supplier") if self.filters.get("supplier_group"): + groups = get_party_group_with_children("Supplier", self.filters.supplier_group) self.qb_selection_filter.append( self.ple.party.isin( - qb.from_(supplier) - .select(supplier.name) - .where(supplier.supplier_group == self.filters.get("supplier_group")) + qb.from_(supplier).select(supplier.name).where(supplier.supplier_group.isin(groups)) ) ) @@ -1090,16 +1095,6 @@ class ReceivablePayableReport: return ptt - def get_hierarchical_filters(self, doctype, key): - lft, rgt = frappe.db.get_value(doctype, self.filters.get(key), ["lft", "rgt"]) - - doc = qb.DocType(doctype) - ple = self.ple - customer = self.customer - groups = qb.from_(doc).select(doc.name).where((doc.lft >= lft) & (doc.rgt <= rgt)) - customers = qb.from_(customer).select(customer.name).where(customer[key].isin(groups)) - self.qb_selection_filter.append(ple.party.isin(customers)) - def add_accounting_dimensions_filters(self): accounting_dimensions = get_accounting_dimensions(as_list=False) @@ -1329,19 +1324,23 @@ def get_party_group_with_children(party, party_groups): if party not in ("Customer", "Supplier"): return [] - group_dtype = f"{party} Group" - if not isinstance(party_groups, list): - party_groups = [d.strip() for d in party_groups.strip().split(",") if d] + return get_nested_set_children(f"{party} Group", party_groups) - all_party_groups = [] - for d in party_groups: - if frappe.db.exists(group_dtype, d): - lft, rgt = frappe.db.get_value(group_dtype, d, ["lft", "rgt"]) - children = frappe.get_all( - group_dtype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name" - ) - all_party_groups += children + +def get_nested_set_children(doctype, values): + if not isinstance(values, list): + values = [d.strip() for d in values.split(",") if d.strip()] + + if not values: + frappe.throw(_("Please select a valid {0}").format(_(doctype))) + + all_values = [] + for d in values: + if frappe.db.exists(doctype, d): + lft, rgt = frappe.db.get_value(doctype, d, ["lft", "rgt"]) + children = frappe.get_all(doctype, filters={"lft": [">=", lft], "rgt": ["<=", rgt]}, pluck="name") + all_values += children else: - frappe.throw(_("{0}: {1} does not exist").format(group_dtype, d)) + frappe.throw(_("{0}: {1} does not exist").format(doctype, d)) - return list(set(all_party_groups)) + return list(set(all_values)) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 7354b48e4a2..3395ad3a34a 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -771,6 +771,38 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): # Assert that the customer group of each row is in the list of customer groups self.assertIn(row.customer_group, cus_groups_list) + def test_territory_filter(self): + self.create_sales_invoice() + territory = frappe.db.get_value("Customer", self.customer, "territory") + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "territory": territory, + } + report = execute(filters)[1] + self.assertEqual(len(report), 1) + self.assertEqual( + [100.0, 100.0, territory], [report[0].invoiced, report[0].outstanding, report[0].territory] + ) + + filters.update({"territory": ["_Test Territory United States"]}) + self.assertEqual(len(execute(filters)[1]), 0) + + filters.update({"territory": [territory, "_Test Territory United States"]}) + self.assertEqual(len(execute(filters)[1]), 1) + + frappe.db.set_value("Customer", self.customer, "territory", "_Test Territory Maharashtra") + filters.update({"territory": ["_Test Territory India"]}) + self.assertEqual(len(execute(filters)[1]), 1) + + filters.update({"territory": ["_Test Territory Mars"]}) + self.assertRaises(frappe.ValidationError, execute, filters) + + filters.update({"territory": " "}) + self.assertRaises(frappe.ValidationError, execute, filters) + def test_party_account_filter(self): si1 = self.create_sales_invoice() self.customer2 = ( diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index c15ec8b0124..3d7121f3836 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -106,8 +106,11 @@ frappe.query_reports["Accounts Receivable Summary"] = { { fieldname: "territory", label: __("Territory"), - fieldtype: "Link", + fieldtype: "MultiSelectList", options: "Territory", + get_data: function (txt) { + return frappe.db.get_link_options("Territory", txt); + }, }, { fieldname: "sales_partner", From ebdc0cdf7f45dcc7d81e2fe7f09ed90ac69f8b93 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:15:59 +0530 Subject: [PATCH 03/60] fix(stock): handle multi-item opening balance in Stock Ledger report (backport #57591) (#57795) * fix(stock): handle multi-item opening balance in Stock Ledger report (#57591) * fix(stock): handle multi-item opening balance in Stock * test(stock): add unit test for multi-item Stock Ledger report --------- Co-authored-by: Afsal Syed (cherry picked from commit 0dbe410414b94649dcb2c47e419f507da13490df) # Conflicts: # erpnext/stock/report/stock_ledger/stock_ledger.py # erpnext/stock/report/stock_ledger/test_stock_ledger_report.py * fix(stock): resolve stock ledger backport conflicts --------- Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Co-authored-by: Sudharsanan11 --- .../stock/report/stock_ledger/stock_ledger.py | 174 ++++++--- .../stock_ledger/test_stock_ledger_report.py | 339 +++++++++++++++++- 2 files changed, 455 insertions(+), 58 deletions(-) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 2152fce31c0..6c4ce0844cf 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -7,8 +7,10 @@ from collections import defaultdict import frappe from frappe import _ -from frappe.query_builder.functions import CombineDatetime, Sum +from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cint, flt, get_datetime +from pypika import Order +from pypika.analytics import RowNumber from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -53,14 +55,15 @@ def execute(filters=None): data = [] conversion_factors = [] - if opening_row: - data.append(opening_row) + opening_rows = opening_row if isinstance(opening_row, list) else ([opening_row] if opening_row else []) + for row in opening_rows: + data.append(row) conversion_factors.append(0) actual_qty = stock_value = 0 - if opening_row: - actual_qty = opening_row.get("qty_after_transaction") - stock_value = opening_row.get("stock_value") + if opening_rows: + actual_qty = opening_rows[0].get("qty_after_transaction", 0) + stock_value = opening_rows[0].get("stock_value", 0) available_serial_nos = {} @@ -687,43 +690,120 @@ def get_opening_balance(filters, columns, sl_entries, inv_dimension_wise_value=N if not (filters.item_code and filters.warehouse and filters.from_date): return - from erpnext.stock.stock_ledger import get_previous_sle + item_codes = filters.item_code + if isinstance(item_codes, str): + item_codes = [item_codes] - project = None - if filters.get("project") and not frappe.get_all( - "Inventory Dimension", filters={"reference_document": "Project"} - ): - project = filters.get("project") + warehouses = get_matching_warehouses(filters.warehouse) + if not warehouses: + return - last_entry = get_previous_sle( - { - "item_code": filters.item_code, - "warehouse_condition": get_warehouse_condition(filters.warehouse), - "posting_date": filters.from_date, - "posting_time": "00:00:00", - "project": project, - }, - for_report=True, + sle_doctype = frappe.qb.DocType("Stock Ledger Entry") + sr_doctype = frappe.qb.DocType("Stock Reconciliation") + + opening_reco_query = ( + frappe.qb.from_(sle_doctype) + .inner_join(sr_doctype) + .on(sle_doctype.voucher_no == sr_doctype.name) + .select(sle_doctype.voucher_no) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_doctype.voucher_type == "Stock Reconciliation") + .where(sle_doctype.posting_date == filters.from_date) + .where(sr_doctype.purpose == "Opening Stock") ) - # check if any SLEs are actually Opening Stock Reconciliation - for sle in list(sl_entries): - if ( - sle.get("voucher_type") == "Stock Reconciliation" - and sle.posting_date == filters.from_date - and frappe.db.get_value("Stock Reconciliation", sle.voucher_no, "purpose") == "Opening Stock" - ): - last_entry = sle - sl_entries.remove(sle) + opening_reco_vouchers = set(opening_reco_query.run(pluck=True)) - row = { + if opening_reco_vouchers: + sl_entries[:] = [sle for sle in sl_entries if sle.get("voucher_no") not in opening_reco_vouchers] + + sle_cond = (sle_doctype.posting_date < filters.from_date) | ( + (sle_doctype.posting_date == filters.from_date) & (sle_doctype.posting_time == "00:00:00") + ) + if opening_reco_vouchers: + sle_cond = sle_cond | ( + (sle_doctype.posting_date == filters.from_date) + & (sle_doctype.voucher_no.isin(list(opening_reco_vouchers))) + ) + + subq = ( + frappe.qb.from_(sle_doctype) + .select( + sle_doctype.qty_after_transaction, + sle_doctype.stock_value, + RowNumber() + .over(sle_doctype.item_code, sle_doctype.warehouse) + .orderby(sle_doctype.posting_datetime, sle_doctype.creation, sle_doctype.name, order=Order.desc) + .as_("rn"), + ) + .where(sle_doctype.docstatus < 2) + .where(sle_doctype.is_cancelled == 0) + .where(sle_doctype.item_code.isin(item_codes)) + .where(sle_doctype.warehouse.isin(warehouses)) + .where(sle_cond) + ) + + for field in ["voucher_no", "project", "company"]: + if filters.get(field): + subq = subq.where(sle_doctype[field] == filters.get(field)) + + inventory_dimension_fields = get_inventory_dimension_fields() + if inventory_dimension_fields: + for fieldname in inventory_dimension_fields: + if filters.get(fieldname): + subq = subq.where(sle_doctype[fieldname].isin(filters.get(fieldname))) + + query = ( + frappe.qb.from_(subq) + .select( + IfNull(Sum(subq.qty_after_transaction), 0.0).as_("total_qty"), + IfNull(Sum(subq.stock_value), 0.0).as_("total_stock_value"), + ) + .where(subq.rn == 1) + ) + + res = query.run(as_dict=True) + + total_qty = flt(res[0].total_qty) if res else 0.0 + total_stock_value = flt(res[0].total_stock_value) if res else 0.0 + valuation_rate = flt(total_stock_value / total_qty) if total_qty else 0.0 + + return { "item_code": _("'Opening'"), - "qty_after_transaction": last_entry.get("qty_after_transaction", 0), - "valuation_rate": last_entry.get("valuation_rate", 0), - "stock_value": last_entry.get("stock_value", 0), + "qty_after_transaction": total_qty, + "valuation_rate": valuation_rate, + "stock_value": total_stock_value, } - return row + +def get_matching_warehouses(warehouses): + if not warehouses: + return [] + + if isinstance(warehouses, str): + warehouses = [warehouses] + + warehouse_details = frappe.get_all( + "Warehouse", + filters={"name": ("in", warehouses)}, + fields=["lft", "rgt"], + ) + + if not warehouse_details: + return warehouses + + wh = frappe.qb.DocType("Warehouse") + cond = None + for d in warehouse_details: + c = (wh.lft >= d.lft) & (wh.rgt <= d.rgt) + cond = c if cond is None else (cond | c) + + matching = (frappe.qb.from_(wh).select(wh.name).where(cond)).run(pluck=True) + + return matching if matching else warehouses def get_warehouse_condition(warehouses): @@ -779,7 +859,15 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): if not filters.item_code or not filters.warehouse or not filters.from_date: return - if len(filters.get("item_code")) > 1 or len(filters.get("warehouse")) > 1: + item_codes = filters.get("item_code") + if isinstance(item_codes, str): + item_codes = [item_codes] + + warehouses = filters.get("warehouse") + if isinstance(warehouses, str): + warehouses = [warehouses] + + if len(item_codes) > 1 or len(warehouses) > 1: return sl_doctype = frappe.qb.DocType("Stock Ledger Entry") @@ -799,17 +887,11 @@ def get_opening_balance_for_inv_dimension(filters, inv_dimension_wise_value): ) ) - if filters.get("item_code"): - if isinstance(filters.item_code, list | tuple): - query = query.where(sl_doctype.item_code.isin(filters.item_code)) - else: - query = query.where(sl_doctype.item_code == filters.item_code) + if item_codes: + query = query.where(sl_doctype.item_code.isin(item_codes)) - if filters.get("warehouse"): - if isinstance(filters.warehouse, list | tuple): - query = query.where(sl_doctype.warehouse.isin(filters.warehouse)) - else: - query = query.where(sl_doctype.warehouse == filters.warehouse) + if warehouses: + query = query.where(sl_doctype.warehouse.isin(warehouses)) for key, value in inv_dimension_wise_value.items(): if isinstance(value, list | tuple): diff --git a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py index d57052e905f..a41658a45c8 100644 --- a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py +++ b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py @@ -5,20 +5,335 @@ import frappe from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, today -from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( - make_serial_item_with_serial, -) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.stock.report.stock_ledger.stock_ledger import execute + +WAREHOUSE = "Stores - _TC" -class TestStockLedgerReeport(FrappeTestCase): - def setUp(self) -> None: - make_serial_item_with_serial("_Test Stock Report Serial Item") - self.filters = frappe._dict( - company="_Test Company", - from_date=today(), - to_date=add_days(today(), 30), - item_code=["_Test Stock Report Serial Item"], - ) +class TestStockLedgerReport(FrappeTestCase): + """Correctness tests for the Stock Ledger report. + + A shared `make_movements`/`run` pair keeps each test small without persisting + any data: movements are created per test and rolled back, while the report runs + read-only. Tests reuse bootstrap items and transact in `Stores - _TC`, which + starts clean (zero balance) for these items. + """ def tearDown(self) -> None: frappe.db.rollback() + + def make_movements(self, item_code, movements): + for movement in movements: + make_stock_entry(item_code=item_code, **movement) + + def run_report(self, item_code, from_date=None, to_date=None): + filters = frappe._dict( + company="_Test Company", + from_date=from_date or add_days(today(), -1), + to_date=to_date or today(), + item_code=[item_code], + warehouse=WAREHOUSE, + ) + return list(execute(filters)[1]) + + def test_in_out_quantities_and_running_balance(self): + item = "_Test Item" + self.make_movements( + item, + [ + {"qty": 10, "to_warehouse": WAREHOUSE, "basic_rate": 100}, + {"qty": 4, "from_warehouse": WAREHOUSE}, + ], + ) + + rows = self.run_report(item) + receipt = next(row for row in rows if row.get("in_qty")) + issue = next(row for row in rows if row.get("out_qty")) + + self.assertEqual(receipt["in_qty"], 10) + self.assertEqual(receipt["qty_after_transaction"], 10) + self.assertEqual(issue["out_qty"], -4) + self.assertEqual(issue["qty_after_transaction"], 6) + + def test_opening_balance_reflects_movements_before_from_date(self): + item = "_Test Item" + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + {"qty": 4, "from_warehouse": WAREHOUSE, "posting_date": today()}, + ], + ) + + rows = self.run_report(item, from_date=add_days(today(), -5), to_date=today()) + + # the receipt predates the range, so it surfaces as the opening balance + self.assertEqual(rows[0]["item_code"], "'Opening'") + self.assertEqual(rows[0]["qty_after_transaction"], 10) + + # the in-range issue draws down from the opening balance + issue = next(row for row in rows if row.get("out_qty")) + self.assertEqual(issue["qty_after_transaction"], 6) + + def test_filters_to_requested_item_only(self): + item_a = "_Test Item" + item_b = "_Test Item 2" + self.make_movements(item_a, [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 100}]) + self.make_movements(item_b, [{"qty": 7, "to_warehouse": WAREHOUSE, "basic_rate": 100}]) + + rows = self.run_report(item_a) + item_codes = {row["item_code"] for row in rows if row.get("voucher_no")} + self.assertEqual(item_codes, {item_a}) + + def test_multi_item_opening_balance_with_and_without_transactions(self): + item_a = "_Test Item" + item_b = "_Test Item 2" + self.make_movements( + item_a, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + self.make_movements( + item_b, + [{"qty": 5, "to_warehouse": WAREHOUSE, "basic_rate": 50, "posting_date": add_days(today(), -10)}], + ) + self.make_movements( + item_a, + [{"qty": 2, "from_warehouse": WAREHOUSE, "posting_date": today()}], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item_a, item_b], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_multi_warehouse_opening_balance_aggregation(self): + item = "_Test Item" + warehouse_1 = "Stores - _TC" + warehouse_2 = "Finished Goods - _TC" + + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": warehouse_1, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + { + "qty": 20, + "to_warehouse": warehouse_2, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + }, + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=[warehouse_1, warehouse_2], + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 30) + + def test_opening_stock_reconciliation_on_from_date_non_midnight_time(self): + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + item = "_Test Item" + from_date = today() + + sr = create_stock_reconciliation( + item_code=item, + warehouse=WAREHOUSE, + qty=25, + rate=100, + posting_date=from_date, + posting_time="10:30:00", + purpose="Opening Stock", + do_not_submit=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=from_date, + to_date=from_date, + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 25) + + # Ensure the Opening Stock Reconciliation is not duplicated in detail transaction rows + reco_rows = [row for row in rows if row.get("voucher_no") == sr.name] + self.assertEqual(len(reco_rows), 0) + + def test_backdated_sle_independent_maxima_handling(self): + item = "_Test Item" + # Entry 1: Later posting date (2026-07-20), created first + self.make_movements( + item, + [ + { + "qty": 10, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -10), + } + ], + ) + # Entry 2: Backdated posting date (2026-07-15), created LATER + self.make_movements( + item, + [ + { + "qty": 5, + "to_warehouse": WAREHOUSE, + "basic_rate": 100, + "posting_date": add_days(today(), -15), + } + ], + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + # Should correctly pick the latest posting date entry (15 Qty) despite backdated creation order + self.assertEqual(opening_rows[0]["qty_after_transaction"], 15) + + def test_filtered_opening_balance_does_not_pick_excluded_creation_entry(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + included_entry = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + make_stock_entry( + item_code=item, + qty=50, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + voucher_no=included_entry.name, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], 10) + + def test_tied_creation_terminal_sle_is_not_summed_twice(self): + item = "_Test Item" + posting_date = add_days(today(), -10) + posting_time = "09:00:00" + + stock_entry_1 = make_stock_entry( + item_code=item, + qty=10, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + stock_entry_2 = make_stock_entry( + item_code=item, + qty=5, + to_warehouse=WAREHOUSE, + basic_rate=100, + posting_date=posting_date, + posting_time=posting_time, + ) + + sle_rows = frappe.get_all( + "Stock Ledger Entry", + filters={ + "voucher_type": "Stock Entry", + "voucher_no": ("in", [stock_entry_1.name, stock_entry_2.name]), + "item_code": item, + "warehouse": WAREHOUSE, + "is_cancelled": 0, + }, + fields=["name", "qty_after_transaction"], + order_by="name desc", + ) + self.assertEqual(len(sle_rows), 2) + + for sle in sle_rows: + frappe.db.set_value( + "Stock Ledger Entry", + sle.name, + "creation", + "2026-01-01 00:00:00.000000", + update_modified=False, + ) + + filters = frappe._dict( + company="_Test Company", + from_date=add_days(today(), -5), + to_date=today(), + item_code=[item], + warehouse=WAREHOUSE, + ) + columns, rows = execute(filters) + + opening_rows = [row for row in rows if row.get("item_code") == "'Opening'"] + self.assertEqual(len(opening_rows), 1) + self.assertEqual(opening_rows[0]["qty_after_transaction"], sle_rows[0].qty_after_transaction) + self.assertNotEqual( + opening_rows[0]["qty_after_transaction"], + sum(sle.qty_after_transaction for sle in sle_rows), + ) From 7be8a99a23b7cc828dbda36992cda4f50dc2c612 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Sun, 2 Aug 2026 02:31:21 +0530 Subject: [PATCH 04/60] fix(stock): validate over delivery/receipt allowance in stock settings (cherry picked from commit 446ec6030a73573f24500f9ff9def1f78a1d387e) # Conflicts: # erpnext/stock/doctype/stock_settings/stock_settings.json # erpnext/stock/doctype/stock_settings/stock_settings.py --- .../doctype/stock_settings/stock_settings.json | 17 ++++++++++++++++- .../doctype/stock_settings/stock_settings.py | 11 ++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 58ea8083087..03303d247bf 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -131,7 +131,8 @@ "description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.", "fieldname": "over_delivery_receipt_allowance", "fieldtype": "Float", - "label": "Over Delivery/Receipt Allowance (%)" + "label": "Over Delivery/Receipt Allowance (%)", + "non_negative": 1 }, { "default": "Stop", @@ -282,7 +283,12 @@ "description": "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units.", "fieldname": "mr_qty_allowance", "fieldtype": "Float", +<<<<<<< HEAD "label": "Over Transfer Allowance" +======= + "label": "Over Transfer Allowance (%)", + "non_negative": 1 +>>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) }, { "default": "0", @@ -446,7 +452,12 @@ "description": "The percentage you are allowed to pick more items in the pick list than the ordered quantity.", "fieldname": "over_picking_allowance", "fieldtype": "Percent", +<<<<<<< HEAD "label": "Over Picking Allowance" +======= + "label": "Over Picking Allowance (%)", + "non_negative": 1 +>>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) }, { "default": "1", @@ -528,7 +539,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2026-03-27 22:39:16.812184", +======= + "modified": "2026-08-01 23:35:02.896836", +>>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index f573c35925b..1c9611d733e 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -67,7 +67,7 @@ class StockSettings(Document): use_naming_series: DF.Check use_serial_batch_fields: DF.Check validate_material_transfer_warehouses: DF.Check - valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] + valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO", "Standard Cost"] # end: auto-generated types def validate(self): @@ -100,7 +100,12 @@ class StockSettings(Document): validate_fields_for_doctype=False, ) +<<<<<<< HEAD self.validate_warehouses() +======= + self.validate_over_delivery_receipt_allowance() + self.validate_serial_and_batch_no_settings() +>>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) self.cant_change_valuation_method() self.validate_clean_description_html() self.validate_pending_reposts() @@ -110,6 +115,10 @@ class StockSettings(Document): self.change_precision_for_purchase() self.validate_do_not_use_batchwise_valuation() + def validate_over_delivery_receipt_allowance(self): + if not self.over_delivery_receipt_allowance: + self.role_allowed_to_over_deliver_receive = None + def validate_do_not_use_batchwise_valuation(self): doc_before_save = self.get_doc_before_save() if not doc_before_save: From 328c72da188d25d2a3e2df7675e1b30469ff0179 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Sun, 2 Aug 2026 02:31:36 +0530 Subject: [PATCH 05/60] fix(stock): scope over deliver/receive role check to delivery and receipt overflow (cherry picked from commit 248873034df67a2953ef7948ddc44d28f202ea97) # Conflicts: # erpnext/controllers/status_updater.py --- erpnext/controllers/status_updater.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 05011a6b9d9..6513ecf7080 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -312,6 +312,7 @@ class StatusUpdater(Document): qty_or_amount, ) +<<<<<<< HEAD role_allowed_to_over_deliver_receive = frappe.db.get_single_value( "Stock Settings", "role_allowed_to_over_deliver_receive" ) @@ -319,6 +320,14 @@ class StatusUpdater(Document): "Accounts Settings", "role_allowed_to_over_bill" ) role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill +======= + role = None + if qty_or_amount == "qty": + if args.get("overflow_type") in ("delivery", "receipt"): + role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive") + else: + role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") +>>>>>>> 248873034d (fix(stock): scope over deliver/receive role check to delivery and receipt overflow) overflow_percent = ( (item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]] From f5ab55c38d54044d43ab9a8b54d93cfb9cd25989 Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Sun, 2 Aug 2026 02:32:59 +0530 Subject: [PATCH 06/60] test(stock): add test cases verifying stock over delivery role does not bypass order allowance (cherry picked from commit 0b271e24b60ed4f3a2f9e57bfd5f39f195a6b683) # Conflicts: # erpnext/buying/doctype/purchase_order/test_purchase_order.py # erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py --- .../purchase_order/test_purchase_order.py | 56 +++++++++++++++++++ .../blanket_order/test_blanket_order.py | 52 +++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index c4394c066e0..3273a40ac7b 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -126,6 +126,62 @@ class TestPurchaseOrder(FrappeTestCase): frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0) frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0) +<<<<<<< HEAD +======= + def test_over_order_allowance_against_material_request(self) -> None: + """Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently + from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO.""" + mr = make_material_request(qty=100) + po = make_purchase_order(mr.name) + po.supplier = "_Test Supplier" + po.items[0].qty = 110 # 10% over the MR qty + + # Without any allowance, submitting should raise an OverAllowanceError + from erpnext.controllers.status_updater import OverAllowanceError + + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + self.assertRaises(OverAllowanceError, po.submit) + + # Granting 10% in Over Order Allowance (Buying Settings) must allow the submit + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) + po.reload() + po.items[0].qty = 110 + po.submit() + self.assertEqual(po.docstatus, 1) + po.cancel() + + # Over Delivery/Receipt Allowance must remain independent — changing it must not + # affect the MR → PO validation when Over Order Allowance is 0. + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) + + mr2 = make_material_request(qty=100) + po2 = make_purchase_order(mr2.name) + po2.supplier = "_Test Supplier" + po2.items[0].qty = 110 + self.assertRaises(OverAllowanceError, po2.submit) + + # Stock over-delivery role must not bypass over-ordering against Material Request. + with self.change_settings( + "Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"} + ): + test_user = frappe.get_doc("User", "test@example.com") + test_user.add_roles("Stock Manager") + + mr3 = make_material_request(qty=100) + po3 = make_purchase_order(mr3.name) + po3.supplier = "_Test Supplier" + po3.items[0].qty = 110 + with self.set_user("test@example.com"): + po3.flags.ignore_permissions = True + self.assertRaises(OverAllowanceError, po3.submit) + + # cleanup + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) + frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) + +>>>>>>> 0b271e24b6 (test(stock): add test cases verifying stock over delivery role does not bypass order allowance) def test_update_remove_child_linked_to_mr(self): """Test impact on linked PO and MR on deleting/updating row.""" mr = make_material_request(qty=10) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index a6eb18f47bc..ce7ef6b614a 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -91,6 +91,58 @@ class TestBlanketOrder(FrappeTestCase): frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10) po.submit() +<<<<<<< HEAD +======= + @ERPNextTestSuite.change_settings("Selling Settings", {"blanket_order_allowance": 0}) + @ERPNextTestSuite.change_settings("Buying Settings", {"blanket_order_allowance": 0}) + @ERPNextTestSuite.change_settings( + "Stock Settings", + {"over_delivery_receipt_allowance": 10, "role_allowed_to_over_deliver_receive": "Stock Manager"}, + ) + def test_stock_over_delivery_role_does_not_bypass_blanket_order_allowance(self): + test_user = frappe.get_doc("User", "test@example.com") + test_user.add_roles("Stock Manager") + + frappe.clear_cache() + for blanket_order_type, doctype, date_field in ( + ("Selling", "Sales Order", "delivery_date"), + ("Purchasing", "Purchase Order", "schedule_date"), + ): + bo = make_blanket_order(blanket_order_type=blanket_order_type, quantity=100) + frappe.flags.args.doctype = doctype + order = make_order(bo.name) + order.currency = get_company_currency(order.company) + setattr(order, date_field, today()) + order.items[0].qty = 110 + + with self.set_user("test@example.com"): + order.flags.ignore_permissions = True + self.assertRaises(frappe.ValidationError, order.submit) + + def test_blanket_order_over_order_aggregated_across_rows(self): + # the over-order check should sum the same item across multiple order rows + frappe.db.set_single_value("Selling Settings", "blanket_order_allowance", 0) + bo = make_blanket_order(blanket_order_type="Selling", quantity=100) + + frappe.flags.args.doctype = "Sales Order" + so = make_order(bo.name) + so.currency = get_company_currency(so.company) + so.delivery_date = today() + so.items[0].qty = 60 + so.append( + "items", + { + "item_code": so.items[0].item_code, + "qty": 50, # 60 + 50 = 110 > 100 blanket qty + "rate": so.items[0].rate, + "delivery_date": today(), + "against_blanket_order": 1, + "blanket_order": bo.name, + }, + ) + self.assertRaises(frappe.ValidationError, so.submit) + +>>>>>>> 0b271e24b6 (test(stock): add test cases verifying stock over delivery role does not bypass order allowance) def test_party_item_code(self): item_doc = make_item("_Test Item 1 for Blanket Order") item_code = item_doc.name From 21c3e07f48f24b831583a0cd6f92a9990132f1af Mon Sep 17 00:00:00 2001 From: Afsal Syed Date: Sun, 2 Aug 2026 02:33:42 +0530 Subject: [PATCH 07/60] test(stock): prevent settings leakage in purchase order tests (cherry picked from commit 99630f40eb5fcef78b889278e001f68de70caa04) # Conflicts: # erpnext/buying/doctype/purchase_order/test_purchase_order.py --- .../buying/doctype/purchase_order/test_purchase_order.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 3273a40ac7b..194f5a7457c 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -1004,6 +1004,8 @@ class TestPurchaseOrder(FrappeTestCase): # self.assertEqual(po.payment_terms_template, pi.payment_terms_template) compare_payment_schedules(self, po, pi) + @ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1}) + @ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1}) def test_internal_transfer_flow(self): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( @@ -1015,9 +1017,6 @@ class TestPurchaseOrder(FrappeTestCase): ) from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt - frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1) - frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1) - prepare_data_for_internal_transfer() supplier = "_Test Internal Supplier 2" @@ -1426,7 +1425,11 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(pi_2.status, "Paid") self.assertEqual(po.status, "Completed") +<<<<<<< HEAD @change_settings("Buying Settings", {"maintain_same_rate": 0}) +======= + @ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0}) +>>>>>>> 99630f40eb (test(stock): prevent settings leakage in purchase order tests) def test_purchase_order_over_billing_missing_item(self): item1 = make_item( "_Test Item for Overbilling", From b25a2f2cdfd1cce7993749b78d59e30e6dbde623 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 12:58:57 +0530 Subject: [PATCH 08/60] fix: resolve version-15 backport conflicts Keep validate_warehouses() and add validate_over_delivery_receipt_allowance(); validate_serial_and_batch_no_settings() does not exist on this branch. Keep this branch's field labels and add only the non_negative flag the backported commit introduced. Drop test_over_order_allowance_against_material_request and test_blanket_order_over_order_aggregated_across_rows: both are develop-only context the cherry-pick swallowed into the conflicts, and Buying Settings has no over_order_allowance field here. Use frappe.tests.utils.change_settings instead of ERPNextTestSuite, which this branch does not have. Revert the valuation_method literal to the three options this branch offers - Standard Cost rode along from a regenerated develop type block. --- .../purchase_order/test_purchase_order.py | 64 +------------------ erpnext/controllers/status_updater.py | 10 --- .../blanket_order/test_blanket_order.py | 34 ++-------- .../stock_settings/stock_settings.json | 16 +---- .../doctype/stock_settings/stock_settings.py | 6 +- 5 files changed, 9 insertions(+), 121 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 194f5a7457c..ac5ec434b76 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -126,62 +126,6 @@ class TestPurchaseOrder(FrappeTestCase): frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0) frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0) -<<<<<<< HEAD -======= - def test_over_order_allowance_against_material_request(self) -> None: - """Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently - from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO.""" - mr = make_material_request(qty=100) - po = make_purchase_order(mr.name) - po.supplier = "_Test Supplier" - po.items[0].qty = 110 # 10% over the MR qty - - # Without any allowance, submitting should raise an OverAllowanceError - from erpnext.controllers.status_updater import OverAllowanceError - - frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) - frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) - self.assertRaises(OverAllowanceError, po.submit) - - # Granting 10% in Over Order Allowance (Buying Settings) must allow the submit - frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10) - po.reload() - po.items[0].qty = 110 - po.submit() - self.assertEqual(po.docstatus, 1) - po.cancel() - - # Over Delivery/Receipt Allowance must remain independent — changing it must not - # affect the MR → PO validation when Over Order Allowance is 0. - frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) - frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) - - mr2 = make_material_request(qty=100) - po2 = make_purchase_order(mr2.name) - po2.supplier = "_Test Supplier" - po2.items[0].qty = 110 - self.assertRaises(OverAllowanceError, po2.submit) - - # Stock over-delivery role must not bypass over-ordering against Material Request. - with self.change_settings( - "Stock Settings", {"role_allowed_to_over_deliver_receive": "Stock Manager"} - ): - test_user = frappe.get_doc("User", "test@example.com") - test_user.add_roles("Stock Manager") - - mr3 = make_material_request(qty=100) - po3 = make_purchase_order(mr3.name) - po3.supplier = "_Test Supplier" - po3.items[0].qty = 110 - with self.set_user("test@example.com"): - po3.flags.ignore_permissions = True - self.assertRaises(OverAllowanceError, po3.submit) - - # cleanup - frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) - frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) - ->>>>>>> 0b271e24b6 (test(stock): add test cases verifying stock over delivery role does not bypass order allowance) def test_update_remove_child_linked_to_mr(self): """Test impact on linked PO and MR on deleting/updating row.""" mr = make_material_request(qty=10) @@ -1004,8 +948,8 @@ class TestPurchaseOrder(FrappeTestCase): # self.assertEqual(po.payment_terms_template, pi.payment_terms_template) compare_payment_schedules(self, po, pi) - @ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1}) - @ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 1}) + @change_settings("Selling Settings", {"maintain_same_sales_rate": 1}) + @change_settings("Buying Settings", {"maintain_same_rate": 1}) def test_internal_transfer_flow(self): from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( @@ -1425,11 +1369,7 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(pi_2.status, "Paid") self.assertEqual(po.status, "Completed") -<<<<<<< HEAD @change_settings("Buying Settings", {"maintain_same_rate": 0}) -======= - @ERPNextTestSuite.change_settings("Buying Settings", {"maintain_same_rate": 0}) ->>>>>>> 99630f40eb (test(stock): prevent settings leakage in purchase order tests) def test_purchase_order_over_billing_missing_item(self): item1 = make_item( "_Test Item for Overbilling", diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 6513ecf7080..34ff799a83c 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -312,22 +312,12 @@ class StatusUpdater(Document): qty_or_amount, ) -<<<<<<< HEAD - role_allowed_to_over_deliver_receive = frappe.db.get_single_value( - "Stock Settings", "role_allowed_to_over_deliver_receive" - ) - role_allowed_to_over_bill = frappe.db.get_single_value( - "Accounts Settings", "role_allowed_to_over_bill" - ) - role = role_allowed_to_over_deliver_receive if qty_or_amount == "qty" else role_allowed_to_over_bill -======= role = None if qty_or_amount == "qty": if args.get("overflow_type") in ("delivery", "receipt"): role = frappe.get_single_value("Stock Settings", "role_allowed_to_over_deliver_receive") else: role = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill") ->>>>>>> 248873034d (fix(stock): scope over deliver/receive role check to delivery and receipt overflow) overflow_percent = ( (item[args["target_field"]] - item[args["target_ref_field"]]) / item[args["target_ref_field"]] diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index ce7ef6b614a..787b3576bc7 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -1,7 +1,7 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_months, today from erpnext import get_company_currency @@ -91,11 +91,9 @@ class TestBlanketOrder(FrappeTestCase): frappe.db.set_single_value("Buying Settings", "blanket_order_allowance", 10) po.submit() -<<<<<<< HEAD -======= - @ERPNextTestSuite.change_settings("Selling Settings", {"blanket_order_allowance": 0}) - @ERPNextTestSuite.change_settings("Buying Settings", {"blanket_order_allowance": 0}) - @ERPNextTestSuite.change_settings( + @change_settings("Selling Settings", {"blanket_order_allowance": 0}) + @change_settings("Buying Settings", {"blanket_order_allowance": 0}) + @change_settings( "Stock Settings", {"over_delivery_receipt_allowance": 10, "role_allowed_to_over_deliver_receive": "Stock Manager"}, ) @@ -119,30 +117,6 @@ class TestBlanketOrder(FrappeTestCase): order.flags.ignore_permissions = True self.assertRaises(frappe.ValidationError, order.submit) - def test_blanket_order_over_order_aggregated_across_rows(self): - # the over-order check should sum the same item across multiple order rows - frappe.db.set_single_value("Selling Settings", "blanket_order_allowance", 0) - bo = make_blanket_order(blanket_order_type="Selling", quantity=100) - - frappe.flags.args.doctype = "Sales Order" - so = make_order(bo.name) - so.currency = get_company_currency(so.company) - so.delivery_date = today() - so.items[0].qty = 60 - so.append( - "items", - { - "item_code": so.items[0].item_code, - "qty": 50, # 60 + 50 = 110 > 100 blanket qty - "rate": so.items[0].rate, - "delivery_date": today(), - "against_blanket_order": 1, - "blanket_order": bo.name, - }, - ) - self.assertRaises(frappe.ValidationError, so.submit) - ->>>>>>> 0b271e24b6 (test(stock): add test cases verifying stock over delivery role does not bypass order allowance) def test_party_item_code(self): item_doc = make_item("_Test Item 1 for Blanket Order") item_code = item_doc.name diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 03303d247bf..488eefeefc7 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -283,12 +283,8 @@ "description": "The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units.", "fieldname": "mr_qty_allowance", "fieldtype": "Float", -<<<<<<< HEAD - "label": "Over Transfer Allowance" -======= - "label": "Over Transfer Allowance (%)", + "label": "Over Transfer Allowance", "non_negative": 1 ->>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) }, { "default": "0", @@ -452,12 +448,8 @@ "description": "The percentage you are allowed to pick more items in the pick list than the ordered quantity.", "fieldname": "over_picking_allowance", "fieldtype": "Percent", -<<<<<<< HEAD - "label": "Over Picking Allowance" -======= - "label": "Over Picking Allowance (%)", + "label": "Over Picking Allowance", "non_negative": 1 ->>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) }, { "default": "1", @@ -539,11 +531,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2026-03-27 22:39:16.812184", -======= "modified": "2026-08-01 23:35:02.896836", ->>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 1c9611d733e..2924e8c70f9 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -67,7 +67,7 @@ class StockSettings(Document): use_naming_series: DF.Check use_serial_batch_fields: DF.Check validate_material_transfer_warehouses: DF.Check - valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO", "Standard Cost"] + valuation_method: DF.Literal["FIFO", "Moving Average", "LIFO"] # end: auto-generated types def validate(self): @@ -100,12 +100,8 @@ class StockSettings(Document): validate_fields_for_doctype=False, ) -<<<<<<< HEAD self.validate_warehouses() -======= self.validate_over_delivery_receipt_allowance() - self.validate_serial_and_batch_no_settings() ->>>>>>> 446ec6030a (fix(stock): validate over delivery/receipt allowance in stock settings) self.cant_change_valuation_method() self.validate_clean_description_html() self.validate_pending_reposts() From 5e7860362efadc3ebe8179a5d59c11a8f8a86134 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 5 Aug 2026 12:22:00 +0530 Subject: [PATCH 09/60] fix(stock): grant account access in blanket order role test --- .../manufacturing/doctype/blanket_order/test_blanket_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py index 787b3576bc7..b28d5a1b41b 100644 --- a/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py @@ -99,7 +99,7 @@ class TestBlanketOrder(FrappeTestCase): ) def test_stock_over_delivery_role_does_not_bypass_blanket_order_allowance(self): test_user = frappe.get_doc("User", "test@example.com") - test_user.add_roles("Stock Manager") + test_user.add_roles("Accounts User", "Stock Manager") frappe.clear_cache() for blanket_order_type, doctype, date_field in ( From 0e9c08d4940c2604d5d45517d0f2a28b0a818f33 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 6 Aug 2026 00:33:58 +0530 Subject: [PATCH 10/60] fix: keep asset repair downtime in sync with entered dates --- .../doctype/asset_repair/asset_repair.js | 45 ++++++++++++------- .../doctype/asset_repair/asset_repair.py | 8 ++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index a477c0bfcbc..8daff02b780 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -86,24 +86,39 @@ frappe.ui.form.on("Asset Repair", { }, repair_status: (frm) => { - if (frm.doc.completion_date && frm.doc.repair_status == "Completed") { - frappe.call({ - method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime", - args: { - failure_date: frm.doc.failure_date, - completion_date: frm.doc.completion_date, - }, - callback: function (r) { - if (r.message) { - frm.set_value("downtime", r.message + " Hrs"); - } - }, - }); - } - if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) { frm.set_value("completion_date", frappe.datetime.now_datetime()); } + + frm.events.set_downtime(frm); + }, + + failure_date: (frm) => { + frm.events.set_downtime(frm); + }, + + completion_date: (frm) => { + frm.events.set_downtime(frm); + }, + + set_downtime: (frm) => { + if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) { + frm.set_value("downtime", null); + return; + } + + frappe.call({ + method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime", + args: { + failure_date: frm.doc.failure_date, + completion_date: frm.doc.completion_date, + }, + callback: function (r) { + if (r.message) { + frm.set_value("downtime", r.message + " Hrs"); + } + }, + }); }, stock_items_on_form_rendered() { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 793101f2072..47a55b3b3c6 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -65,6 +65,7 @@ class AssetRepair(AccountsController): self.set_stock_items_cost() self.calculate_total_repair_cost() self.validate_purchase_invoice_status() + self.set_downtime() def validate_purchase_invoice_status(self): if self.purchase_invoice: @@ -215,6 +216,13 @@ class AssetRepair(AccountsController): if self.repair_status == "Pending": frappe.throw(_("Please update Repair Status.")) + def set_downtime(self): + # keep downtime in sync with the entered dates, regardless of edit order + if self.repair_status == "Completed" and self.failure_date and self.completion_date: + self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs" + else: + self.downtime = None + def check_for_stock_items_and_warehouse(self): if not self.get("stock_items"): frappe.throw(_("Please enter Stock Items consumed during the Repair."), title=_("Missing Items")) From 242c950d1bc8d1f1d531a614195f2863cddbf6f8 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 6 Aug 2026 00:33:58 +0530 Subject: [PATCH 11/60] test: assert asset repair downtime recalculates on date change --- .../doctype/asset_repair/test_asset_repair.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 3a92f0ec71a..89e75de34bf 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -100,6 +100,21 @@ class TestAssetRepair(unittest.TestCase): asset_repair = create_asset_repair(submit=1) self.assertNotEqual(asset_repair.repair_status, "Pending") + def test_downtime_stays_in_sync_with_dates(self): + asset = create_asset(submit=1) + asset_repair = create_asset_repair(asset=asset) + + asset_repair.failure_date = "2026-07-31 09:00:00" + asset_repair.completion_date = "2026-07-31 11:00:00" + asset_repair.repair_status = "Completed" + asset_repair.save() + self.assertEqual(asset_repair.downtime, "2.0 Hrs") + + # editing a date must refresh downtime, not leave a stale value + asset_repair.completion_date = "2026-07-31 14:30:00" + asset_repair.save() + self.assertEqual(asset_repair.downtime, "5.5 Hrs") + def test_stock_items(self): asset_repair = create_asset_repair(stock_consumption=1) self.assertTrue(asset_repair.stock_consumption) From ce898a96956a23ae2d12fec02a7eb7a8c73c5f92 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 6 Aug 2026 11:25:35 +0530 Subject: [PATCH 12/60] fix: clear deferred revenue/expense fields on uncheck (backport #57140) --- erpnext/controllers/accounts_controller.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7f2afecaf9f..51e00e074a1 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -258,6 +258,7 @@ class AccountsController(TransactionBase): if self.is_return: self.validate_qty() else: + self.clear_stale_deferred_fields() self.validate_deferred_start_and_end_date() self.validate_inter_company_reference() @@ -643,6 +644,23 @@ class AccountsController(TransactionBase): if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date): frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date")) + def clear_stale_deferred_fields(self): + field_map = { + "Sales Invoice": "deferred_revenue_account", + "Purchase Invoice": "deferred_expense_account", + } + account_field = field_map.get(self.doctype) + + for item in self.get("items"): + if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"): + continue + + item.service_start_date = None + item.service_end_date = None + item.service_stop_date = None + if account_field: + item.set(account_field, None) + def validate_deferred_start_and_end_date(self): for d in self.items: if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"): From e2ded11e0922a78fa0eab94af776bf4ff86bbacd Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 6 Aug 2026 15:20:34 +0530 Subject: [PATCH 13/60] fix: purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-15-hotfix) (#57837) * fix: use current batch avg rate for outward returns of batchwise valuation batches * fix: honor zero batch average and avoid duplicate batch classification query --- .../purchase_receipt/test_purchase_receipt.py | 60 +++++++++++++++++++ .../serial_and_batch_bundle.py | 48 +++++++++++++++ erpnext/stock/serial_batch_bundle.py | 5 ++ 3 files changed, 113 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 471de86f0a7..40cf324010d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4996,6 +4996,66 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(frappe.parse_json(stock_queue), [[20, 0.0]]) + def test_purchase_return_valuation_for_batchwise_valuation_batch(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code = make_item( + "Test Purchase Return Batchwise Valn Item", + { + "is_stock_item": 1, + "has_batch_no": 1, + "batch_number_series": "BN-TPRBWV-.#####", + }, + ).name + + batch_no = "BN-TPRBWV-00001" + batch = frappe.new_doc("Batch").update({"batch_id": batch_no, "item": item_code}).insert() + self.assertEqual(batch.use_batchwise_valuation, 1) + + warehouse = "_Test Warehouse - _TC" + pr = make_purchase_receipt( + item_code=item_code, + qty=100, + rate=1000, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + make_purchase_receipt( + item_code=item_code, + qty=100, + rate=400, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + create_delivery_note( + item_code=item_code, + qty=100, + warehouse=warehouse, + batch_no=batch_no, + use_serial_batch_fields=1, + ) + + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": return_pr.name, "is_cancelled": 0}, + ["stock_value_difference", "qty_after_transaction", "stock_value", "serial_and_batch_bundle"], + as_dict=True, + ) + self.assertEqual(flt(sle.qty_after_transaction), 0.0) + self.assertEqual(flt(sle.stock_value_difference, 2), -70000.0) + self.assertEqual(flt(sle.stock_value, 2), 0.0) + + rate = frappe.db.get_value( + "Serial and Batch Entry", {"parent": sle.serial_and_batch_bundle}, "incoming_rate" + ) + self.assertEqual(flt(rate, 2), 700.0) + def test_negative_stock_error_for_purchase_return(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry 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 ca18baac969..0b614be1596 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 @@ -402,6 +402,13 @@ class SerialandBatchBundle(Document): valuation_method = get_valuation_method(self.item_code) + # An outward return must go out at the batch's current average rate for a + # batchwise valuation batch. The original receipt rate is only correct while + # the batch still holds stock at that rate; once other receipts have changed + # the average, removing at the original rate strands a residue in the batch + # value (negative when returning the costlier receipt). + batchwise_avg_rates = self.get_batchwise_return_avg_rates() + stock_queue = [] non_batchwise_batches = [] if not self.has_serial_no and valuation_method == "FIFO": @@ -435,6 +442,12 @@ class SerialandBatchBundle(Document): batches = sorted(list(valuation_details["batches"].keys())) valuation_rate = valuation_details["batches"].get(batches[cint(row.idx) - 1]) + # a batch with an available balance goes out at its current average rate (a + # valid 0.0 included); the original receipt rate applies only when there is + # no balance to average + if not row.serial_no and row.batch_no in batchwise_avg_rates: + valuation_rate = batchwise_avg_rates[row.batch_no] + row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) @@ -463,6 +476,41 @@ class SerialandBatchBundle(Document): elif self.type_of_transaction == "Inward": self.set_incoming_rate_for_inward_transaction(row, save, prev_sle=prev_sle) + def get_batchwise_return_avg_rates(self): + from erpnext.stock.utils import get_valuation_method + + if self.type_of_transaction != "Outward" or self.has_serial_no: + return {} + + batch_nos = [d.batch_no for d in self.entries if d.batch_no] + if not batch_nos: + return {} + + if get_valuation_method(self.item_code) == "Moving Average" and frappe.db.get_single_value( + "Stock Settings", "do_not_use_batchwise_valuation" + ): + return {} + + batchwise_batches = frappe.get_all( + "Batch", + filters={"name": ("in", batch_nos), "use_batchwise_valuation": 1}, + pluck="name", + ) + if not batchwise_batches: + return {} + + # scoped to batchwise batches only, so BatchNoValuation's non-batchwise + # machinery never runs for them + sle = self.get_sle_for_outward_transaction() + sle.batch_nos = {batch_no: sle.batch_nos[batch_no] for batch_no in batchwise_batches} + sle.batchwise_valuation_batches = batchwise_batches + sn_obj = BatchNoValuation(sle=sle, item_code=self.item_code, warehouse=self.warehouse) + return { + batch_no: abs(flt(sn_obj.batch_avg_rate.get(batch_no))) + for batch_no in batchwise_batches + if flt(sn_obj.available_qty.get(batch_no)) + } + def validate_returned_serial_batch_no(self, return_against, row, original_inv_details): if frappe.flags.through_repost_item_valuation: return diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 4e0d69134bb..dc659cb79ab 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -910,6 +910,11 @@ class BatchNoValuation(DeprecatedBatchNoValuation): self.batchwise_valuation_batches = [] self.non_batchwise_valuation_batches = [] + if batchwise_batches := self.sle.get("batchwise_valuation_batches"): + self.batchwise_valuation_batches = list(batchwise_batches) + self.non_batchwise_valuation_batches = list(set(self.batches) - set(batchwise_batches)) + return + if get_valuation_method(self.sle.item_code) == "Moving Average" and frappe.db.get_single_value( "Stock Settings", "do_not_use_batchwise_valuation" ): From 52a1b124b229fd331b39088436cf3410bfc1d7e2 Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Fri, 7 Aug 2026 13:44:57 +0530 Subject: [PATCH 14/60] fix: optimize product bundle item search (cherry picked from commit b3867f142890c46e178afe1ebe7d96f32582a22a) --- erpnext/selling/doctype/product_bundle/product_bundle.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 67b9ae5ba31..d65cddba5f0 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -9,6 +9,11 @@ frappe.ui.form.on("Product Bundle", { query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code", }; }); + frm.set_query("item_code", "items", () => { + return { + query: "erpnext.controllers.queries.item_query", + }; + }); frm.set_query("item_code", "items", () => { return { From 140e06dfa46e6e17fb5e76873d1ab42c1732863e Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 7 Aug 2026 19:33:45 +0530 Subject: [PATCH 15/60] fix: guard reconciliation table deletes when tables are missing (cherry picked from commit 8a2b2a2b68608cb626dbf22fd5672f55da9830d6) --- .../patches/v14_0/clear_reconciliation_values_from_singles.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py b/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py index c1f5b60a406..21b31e3bda9 100644 --- a/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py +++ b/erpnext/patches/v14_0/clear_reconciliation_values_from_singles.py @@ -1,3 +1,4 @@ +import frappe from frappe import qb @@ -13,5 +14,8 @@ def execute(): "Payment Reconciliation Allocation", ] for x in doctypes: + # child tables may not exist yet on sites where this pre-model-sync patch runs first + if not frappe.db.table_exists(x): + continue dt = qb.DocType(x) qb.from_(dt).delete().run() From 529ff251fb774e89b130fa75f30268d1c558b4f2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 11:01:21 +0530 Subject: [PATCH 16/60] fix: incorrect entry detection in Stock Ledger Invariant Check (#57886) (cherry picked from commit b3f97cd38965c08fa10a27216fea03a45cd2684d) --- .../stock_ledger_invariant_check.py | 15 ++++++++-- .../test_stock_ledger_invariant_check.py | 30 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index ffb024acfb1..7827203ae92 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -7,6 +7,8 @@ import frappe from frappe import _ from frappe.utils import cint, flt, get_link_to_form, parse_json +from erpnext.stock.utils import get_valuation_method + SLE_FIELDS = ( "name", "posting_date", @@ -53,6 +55,9 @@ def add_invariant_check_fields(sles, filters): balance_qty = 0.0 balance_stock_value = 0.0 + company = frappe.get_cached_value("Warehouse", filters.warehouse, "company") + valuation_method = get_valuation_method(filters.item_code, company) + incorrect_idx = None float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 currency_precision = ( @@ -90,7 +95,7 @@ def add_invariant_check_fields(sles, filters): ) sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value - if maintains_fifo_queue(sle): + if maintains_fifo_queue(sle, valuation_method): add_fifo_fields(sle, sles[idx - 1] if idx else None) if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision): @@ -104,8 +109,10 @@ def add_invariant_check_fields(sles, filters): return sles -def maintains_fifo_queue(sle): - # no queue is maintained for serialized/batchwise-valued stock +def maintains_fifo_queue(sle, valuation_method): + if valuation_method == "Moving Average": + return False + return not ( sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation) ) @@ -138,6 +145,8 @@ def is_sle_has_correct_data(sle, float_precision, currency_precision): return ( flt(sle.difference_in_qty, float_precision) == 0.0 and flt(sle.diff_value_diff, currency_precision) == 0.0 + and flt(sle.fifo_qty_diff, float_precision) == 0.0 + and flt(sle.fifo_value_diff, currency_precision) == 0.0 ) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py index b82e341c84a..67382375862 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py @@ -1,6 +1,8 @@ # Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase @@ -60,6 +62,34 @@ class TestStockLedgerInvariantCheck(FrappeTestCase): self.assertEqual(len(data), 2) # incorrect entry + one before it for context self.assertEqual(data[-1].name, sle.name) + def test_show_incorrect_entries_catches_queue_mismatch(self): + item = self.make_movements() + + sle = frappe.get_last_doc( + "Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0} + ) + tampered_queue = json.dumps([[sle.qty_after_transaction + 5, 100]]) + frappe.db.set_value("Stock Ledger Entry", sle.name, "stock_queue", tampered_queue) + + data = self.run_report(item_code=item, show_incorrect_entries=1) + self.assertEqual(len(data), 2) + self.assertEqual(data[-1].name, sle.name) + + def test_moving_average_item_skips_fifo_queue_checks(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item(properties={"valuation_method": "Moving Average"}).name + make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100) + make_stock_entry(item_code=item, from_warehouse=WAREHOUSE, qty=4) + + data = self.run_report(item_code=item) + self.assertTrue(data) + for row in data: + self.assertIsNone(row.fifo_qty_diff) + self.assertIsNone(row.fifo_value_diff) + + self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), []) + def test_batch_item_skips_fifo_queue_checks(self): item = make_item( properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"} From 5738cfce794a5a5de624d3e72763a297b6cd1122 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 10:48:24 +0530 Subject: [PATCH 17/60] fix: repost read stale sibling SLE rate for moving average returns During repost, a return line with recalculate_rate resolved its moving average rate through get_incoming_rate -> get_previous_sle, which matches posting_datetime <= and orders by creation desc. For a multi-line return of the same item, every line shares one posting_datetime, so the query landed on a sibling line of the same voucher whose stored valuation_rate was still the previous repost run's output, not the rate before the voucher. Each repost run therefore re-seeded the voucher from its own prior output. The error gain per run is (qty returned at the stale rate) / (qty remaining after the return), so whenever a return removes most of the stock the loop diverges instead of converging, alternating sign and growing until stock_value overflows decimal(21,9) and the repost dies with 'Out of range value for column stock_value'. Use the in-memory running valuation rate that update_entries_after already tracks for the warehouse at this point in the repost. It is the authoritative pre-entry state, is immune to sibling rows, and makes the repost idempotent. The database lookup is kept only as a fallback for a zero in-memory rate, preserving the existing zero-rate fallback chain. --- erpnext/stock/stock_ledger.py | 36 ++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a2cce420e3a..f7626931243 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1260,23 +1260,25 @@ class update_entries_after: and not sle.get("batch_no") and not sle.get("serial_and_batch_bundle") ): - rate = get_incoming_rate( - { - "item_code": sle.item_code, - "warehouse": sle.warehouse, - "posting_date": sle.posting_date, - "posting_time": sle.posting_time, - "qty": sle.actual_qty, - "serial_no": sle.get("serial_no"), - "batch_no": sle.get("batch_no"), - "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), - "company": sle.company, - "voucher_type": sle.voucher_type, - "voucher_no": sle.voucher_no, - "allow_zero_valuation": self.allow_zero_rate, - "sle": sle.name, - } - ) + rate = flt(self.wh_data.valuation_rate) + if not rate: + rate = get_incoming_rate( + { + "item_code": sle.item_code, + "warehouse": sle.warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + "qty": sle.actual_qty, + "serial_no": sle.get("serial_no"), + "batch_no": sle.get("batch_no"), + "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), + "company": sle.company, + "voucher_type": sle.voucher_type, + "voucher_no": sle.voucher_no, + "allow_zero_valuation": self.allow_zero_rate, + "sle": sle.name, + } + ) if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]: rate = get_rate_for_return( From 9c8d5ac8a54f784401bcbd694d9b096dbb3d356c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 10:53:04 +0530 Subject: [PATCH 18/60] test: repost of multi-line moving average return is idempotent Reposting a return that removes most of the stock across several lines of the same item must keep every line at the running average and produce identical results on a second repost. Before the fix the first repost already drifted, seeding each line from a sibling row of the same voucher. --- .../test_repost_item_valuation.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index 5291b2e4381..85c7372be59 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -475,6 +475,55 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): # incoming rate after reposting should be 150 self.assertSLEs(se, [{"incoming_rate": 150}]) + def test_repost_multi_line_moving_average_return(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + item = self.make_item(properties={"valuation_method": "Moving Average"}).name + warehouse = "_Test Warehouse - _TC" + + make_purchase_receipt(item_code=item, qty=100, rate=100, warehouse=warehouse) + + pr = make_purchase_receipt(item_code=item, qty=400, rate=200, warehouse=warehouse, do_not_submit=1) + for qty in (100, 300, 100): + pr.append( + "items", + { + "item_code": item, + "warehouse": warehouse, + "qty": qty, + "received_qty": qty, + "rate": 200, + "uom": pr.items[0].uom, + "conversion_factor": 1.0, + }, + ) + pr.save() + pr.submit() + + return_pr = make_return_doc(pr.doctype, pr.name) + return_pr.save() + return_pr.submit() + + expected_sles = [ + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 600.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 500.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 200.0}, + {"outgoing_rate": 190.0, "valuation_rate": 190.0, "qty_after_transaction": 100.0}, + ] + + for _ in range(2): + riv = frappe.get_doc( + doctype="Repost Item Valuation", + based_on="Transaction", + voucher_type=pr.doctype, + voucher_no=pr.name, + posting_date=pr.posting_date, + posting_time=pr.posting_time, + ) + riv.submit() + + self.assertSLEs(return_pr, expected_sles) + def test_remove_attached_file(self): item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1}) From 8131af940563ddb1b6cd99d7d0def0277208563b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 11:00:47 +0530 Subject: [PATCH 19/60] fix: zero-rate repost fallback could still read sibling SLE When the in-memory running rate is zero, the fallback went through get_incoming_rate, whose previous-SLE lookup matches the same posting_datetime and can land on a sibling line of the voucher being replayed. Replace it with get_previous_sle_of_current_voucher excluding the current voucher, keeping the get_valuation_rate chain when no previous entry exists. get_incoming_rate is no longer used in this module. --- erpnext/stock/stock_ledger.py | 53 ++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index f7626931243..52ca6418fc1 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -38,7 +38,6 @@ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry impor from erpnext.stock.utils import ( get_combine_datetime, get_incoming_outgoing_rate_for_cancel, - get_incoming_rate, get_or_make_bin, get_serial_nos_data, get_stock_balance, @@ -1260,25 +1259,7 @@ class update_entries_after: and not sle.get("batch_no") and not sle.get("serial_and_batch_bundle") ): - rate = flt(self.wh_data.valuation_rate) - if not rate: - rate = get_incoming_rate( - { - "item_code": sle.item_code, - "warehouse": sle.warehouse, - "posting_date": sle.posting_date, - "posting_time": sle.posting_time, - "qty": sle.actual_qty, - "serial_no": sle.get("serial_no"), - "batch_no": sle.get("batch_no"), - "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"), - "company": sle.company, - "voucher_type": sle.voucher_type, - "voucher_no": sle.voucher_no, - "allow_zero_valuation": self.allow_zero_rate, - "sle": sle.name, - } - ) + rate = self.get_moving_average_rate_for_return(sle) if not rate and sle.voucher_type in ["Delivery Note", "Sales Invoice"]: rate = get_rate_for_return( @@ -1346,6 +1327,38 @@ class update_entries_after: return rate + def get_moving_average_rate_for_return(self, sle): + """Rate just before this entry, taken from the in-memory running state so a + multi-line return never reads a sibling row of its own voucher.""" + rate = flt(self.wh_data.valuation_rate) + if rate: + return rate + + previous_sle = get_previous_sle_of_current_voucher( + frappe._dict( + item_code=sle.item_code, + warehouse=sle.warehouse, + posting_date=sle.posting_date, + posting_time=sle.posting_time, + voucher_no=sle.voucher_no, + ), + exclude_current_voucher=True, + ) + + rate = previous_sle.get("valuation_rate") + if rate is None: + rate = get_valuation_rate( + sle.item_code, + sle.warehouse, + sle.voucher_type, + sle.voucher_no, + self.allow_zero_rate, + currency=erpnext.get_company_currency(sle.company), + company=sle.company, + ) + + return flt(rate) + def update_outgoing_rate_on_transaction(self, sle): """ Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return From c45ea3545db88f00619bb3d5de9941a32723c2b3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 8 Aug 2026 11:24:44 +0530 Subject: [PATCH 20/60] fix: get_valuation_method takes no company argument on version-15 --- .../stock_ledger_invariant_check.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py index 7827203ae92..ca5e3eec3c8 100644 --- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py +++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py @@ -55,8 +55,7 @@ def add_invariant_check_fields(sles, filters): balance_qty = 0.0 balance_stock_value = 0.0 - company = frappe.get_cached_value("Warehouse", filters.warehouse, "company") - valuation_method = get_valuation_method(filters.item_code, company) + valuation_method = get_valuation_method(filters.item_code) incorrect_idx = None float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3 From af7953a933189fba01bc00a0408bb40d655c645c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:04:14 +0530 Subject: [PATCH 21/60] fix: set `restrict_globals=True` in `frappe.render_template` (backport #57899) (#57901) Co-authored-by: Diptanil Saha --- erpnext/accounts/custom/address.py | 4 +++- erpnext/accounts/doctype/payment_request/payment_request.py | 2 +- .../doctype/request_for_quotation/request_for_quotation.py | 4 ++-- erpnext/crm/doctype/contract_template/contract_template.py | 4 ++-- erpnext/crm/doctype/email_campaign/email_campaign.py | 4 ++-- erpnext/stock/doctype/delivery_trip/delivery_trip.py | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index 246aee3b0ec..53b304b6916 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -65,4 +65,6 @@ def get_shipping_address(company, address=None): if address: address_as_dict = address[0] name, address_template = get_address_templates(address_as_dict) - return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict) + return address_as_dict.get("name"), frappe.render_template( + address_template, address_as_dict, restrict_globals=True + ) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index f5dc2fb479e..218ab9196f3 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -461,7 +461,7 @@ class PaymentRequest(Document): } if self.message: - return frappe.render_template(self.message, context) + return frappe.render_template(self.message, context, restrict_globals=True) def set_failed(self): pass diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 95b0ec8b389..c4ada801cd2 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -325,14 +325,14 @@ class RequestforQuotation(BuyingController): message_template = self.mfs_html if self.use_html else self.message_for_supplier # nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti - rendered_message = frappe.render_template(message_template, doc_args) + rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True) subject_source = ( self.subject or frappe.get_value("Email Template", self.email_template, "subject") or _("Request for Quotation") ) - rendered_subject = frappe.render_template(subject_source, doc_args) + rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True) if preview: return { "message": rendered_message, diff --git a/erpnext/crm/doctype/contract_template/contract_template.py b/erpnext/crm/doctype/contract_template/contract_template.py index 700197500fb..d0bf738d79d 100644 --- a/erpnext/crm/doctype/contract_template/contract_template.py +++ b/erpnext/crm/doctype/contract_template/contract_template.py @@ -30,7 +30,7 @@ class ContractTemplate(Document): def validate(self): if self.contract_terms: - validate_template(self.contract_terms) + validate_template(self.contract_terms, restrict_globals=True) @frappe.whitelist() @@ -42,6 +42,6 @@ def get_contract_template(template_name, doc): contract_terms = None if contract_template.contract_terms: - contract_terms = frappe.render_template(contract_template.contract_terms, doc) + contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True) return {"contract_template": contract_template, "contract_terms": contract_terms} diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py index 4454ede5310..dbc4382a041 100644 --- a/erpnext/crm/doctype/email_campaign/email_campaign.py +++ b/erpnext/crm/doctype/email_campaign/email_campaign.py @@ -171,8 +171,8 @@ def send_mail(entry, email_campaign): context = {"doc": frappe.get_doc("Email Group", recipient)} # Render template - subject = frappe.render_template(email_template.get("subject"), context) - content = frappe.render_template(email_template.response_, context) + subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True) + content = frappe.render_template(email_template.response_, context, restrict_globals=True) try: comm = make( diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index e1e308c735e..fbaafbe73fd 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -406,7 +406,7 @@ def notify_customers(delivery_trip): frappe.sendmail( recipients=contact_info.email_id, subject=dispatch_template.subject, - message=frappe.render_template(dispatch_template.response, context), + message=frappe.render_template(dispatch_template.response, context, restrict_globals=True), attachments=get_attachments(stop), ) From 680ce70c35b997fd6ae211dd2b7f9c1f857c45d6 Mon Sep 17 00:00:00 2001 From: nishkagosalia Date: Thu, 6 Aug 2026 12:51:19 +0530 Subject: [PATCH 22/60] fix: validate webform for project (cherry picked from commit 126966d1db973957725f87fcbe87e9630d49328d) --- erpnext/projects/doctype/task/task.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/erpnext/projects/doctype/task/task.py b/erpnext/projects/doctype/task/task.py index 7eb55272eb8..5028c60f1ad 100755 --- a/erpnext/projects/doctype/task/task.py +++ b/erpnext/projects/doctype/task/task.py @@ -88,6 +88,7 @@ class Task(NestedSet): self.validate_dependencies_for_template_task() self.validate_completed_on() self.validate_parent_is_group() + self.validate_web_form_project_permission() def validate_dates(self): self.validate_from_to_dates("exp_start_date", "exp_end_date") @@ -287,6 +288,23 @@ class Task(NestedSet): if project_user: return True + def validate_web_form_project_permission(self): + project_unchanged = not self.is_new() and self.project == self.get_db_value("project") + + if ( + not frappe.flags.in_web_form + or not self.project + or project_unchanged + or frappe.has_permission("Project", "write", doc=self.project) + or self.has_webform_permission() + ): + return + + frappe.throw( + _("You are not permitted to create a Task for Project {0}").format(self.project), + frappe.PermissionError, + ) + def populate_depends_on(self): if self.parent_task: parent = frappe.get_doc("Task", self.parent_task) From 76f485ba438a7d545db6acb3c1a256187ae1de28 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Fri, 7 Aug 2026 23:02:29 +0530 Subject: [PATCH 23/60] fix: allow selecting a warehouse for new items in the update items dialog (#57876) (cherry picked from commit 55fe26904622273a0089695197e666237bc87c8b) # Conflicts: # erpnext/accounts/services/child_item_update.py # erpnext/public/js/utils.js # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../accounts/services/child_item_update.py | 618 ++++++++++++++++++ .../purchase_order/test_purchase_order.py | 53 +- erpnext/public/js/utils.js | 46 ++ .../doctype/sales_order/test_sales_order.py | 115 ++++ 4 files changed, 829 insertions(+), 3 deletions(-) create mode 100644 erpnext/accounts/services/child_item_update.py diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py new file mode 100644 index 00000000000..d66c5621f7a --- /dev/null +++ b/erpnext/accounts/services/child_item_update.py @@ -0,0 +1,618 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API.""" + +import frappe +from frappe import _ +from frappe.model.workflow import get_workflow_name +from frappe.utils import flt, get_link_to_form, getdate + +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions +from erpnext.buying.utils import update_last_purchase_rate +from erpnext.stock.doctype.packed_item.packed_item import make_packing_list +from erpnext.stock.get_item_details import ( + get_bin_details, + get_conversion_factor, + get_item_warehouse_, +) +from erpnext.stock.utils import ( + is_group_warehouse, + validate_disabled_warehouse, + validate_warehouse_company, +) + + +class ChildItemUpdater: + """Validates and applies item-level edits on submitted orders and quotations.""" + + def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"): + self.parent_doctype = parent_doctype + self.parent_doctype_name = parent_doctype_name + self.child_docname = child_docname + self.parent = frappe.get_doc(parent_doctype, parent_doctype_name) + self.allow_zero_qty = get_allow_zero_qty(parent_doctype) + self._ordered_items: dict | None = None + self._purchased_items: dict | None = None + + def update(self, trans_items: str | list) -> None: + """Process item additions, edits, and deletions from trans_items JSON.""" + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.mapper import get_ordered_items + + data = frappe.parse_json(trans_items) + any_qty_changed = False + items_added_or_removed = False + any_conversion_factor_changed = False + + self._check_permissions("write") + + if self.parent_doctype == "Quotation": + self._ordered_items = get_ordered_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items) + elif self.parent_doctype == "Supplier Quotation": + self._purchased_items = get_purchased_items(self.parent.name) + items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items) + else: + items_added_or_removed |= validate_and_delete_children(self.parent, data) + + for d in data: + new_child_flag = False + rate_unchanged = None + + if not d.get("item_code"): + continue + + if not d.get("docname"): + new_child_flag = True + items_added_or_removed = True + self._check_permissions("create") + child_item = self._get_new_child_item(d) + else: + self._check_permissions("write") + child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname")) + + change_state = get_child_item_change_state(self.parent_doctype, child_item, d) + rate_unchanged = change_state.rate_unchanged + any_conversion_factor_changed |= not change_state.conversion_factor_unchanged + if is_child_item_unchanged(change_state): + continue + + self._validate_quantity_and_rate(child_item, d, rate_unchanged) + + if flt(child_item.get("qty")) != flt(d.get("qty")): + any_qty_changed = True + + if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted: + self._validate_fg_item_for_subcontracting(d, new_child_flag) + child_item.fg_item_qty = flt(d["fg_item_qty"]) + if new_child_flag: + child_item.fg_item = d["fg_item"] + + child_item.qty = flt(d.get("qty")) + child_item.description = d.get("description") + update_child_item_rate_and_discount( + self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged + ) + update_child_item_uom_and_weight(child_item, d) + + if d.get("delivery_date") and self.parent_doctype == "Sales Order": + child_item.delivery_date = d.get("delivery_date") + + if d.get("schedule_date") and self.parent_doctype == "Purchase Order": + child_item.schedule_date = d.get("schedule_date") + + if d.get("bom_no") and self.parent_doctype == "Sales Order": + child_item.bom_no = d.get("bom_no") + + child_item.flags.ignore_validate_update_after_submit = True + if new_child_flag: + self.parent.load_from_db() + child_item.idx = len(self.parent.items) + 1 + child_item.insert() + else: + child_item.save(ignore_permissions=True) + + self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed) + + def _post_update( + self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool + ) -> None: + parent = self.parent + parent.reload() + parent.flags.ignore_validate_update_after_submit = True + parent.set_qty_as_per_stock_uom() + parent.calculate_taxes_and_totals() + parent.set_total_in_words() + + if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: + make_packing_list(parent) + parent.set_gross_profit() + + frappe.get_cached_doc("Authorization Control").validate_approving_authority( + parent.doctype, parent.company, parent.base_grand_total + ) + + if self.parent_doctype != "Supplier Quotation": + from erpnext.accounts.services.payment_schedule import PaymentScheduleService + + PaymentScheduleService(parent).set_payment_schedule() + + if self.parent_doctype == "Purchase Order": + parent.validate_minimum_order_qty() + parent.validate_budget() + if parent.is_against_so(): + parent.update_status_updater() + elif self.parent_doctype == "Sales Order": + parent.check_credit_limit() + + for idx, row in enumerate(parent.get(self.child_docname), start=1): + row.idx = idx + + parent.save() + + if self.parent_doctype == "Purchase Order": + update_last_purchase_rate(parent, is_submit=1) + + if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: + parent.update_prevdoc_status() + + parent.update_requested_qty() + parent.update_ordered_qty() + parent.update_ordered_and_reserved_qty() + parent.update_receiving_percentage() + + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." + ).format(frappe.bold(parent.name)) + ) + + elif self.parent_doctype == "Sales Order": + if parent.is_subcontracted and not parent.can_update_items(): + frappe.throw( + _( + "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." + ) + ) + parent.validate_selling_price() + parent.validate_for_duplicate_items() + parent.validate_warehouse() + parent.update_reserved_qty() + parent.update_project() + parent.update_prevdoc_status("submit") + parent.update_delivery_status() + + parent.reload() + self._validate_workflow() + + if self.parent_doctype in ("Purchase Order", "Sales Order"): + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() + + parent.validate_uom_is_integer("uom", "qty") + parent.validate_uom_is_integer("stock_uom", "stock_qty") + + if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + has_reserved_stock, + ) + + if has_reserved_stock(parent.doctype, parent.name): + cancel_stock_reservation_entries(parent.doctype, parent.name) + if parent.per_picked == 0: + parent.create_stock_reservation_entries() + + def _check_permissions(self, perm_type: str = "create") -> None: + try: + self.parent.check_permission(perm_type) + except frappe.PermissionError: + actions = {"create": "add", "write": "update"} + frappe.throw( + _("You do not have permissions to {0} items in a {1}.").format( + actions[perm_type], self.parent_doctype + ), + title=_("Insufficient Permissions"), + ) + + def _validate_workflow(self) -> None: + workflow = get_workflow_name(self.parent.doctype) + if not workflow: + return + + workflow_doc = frappe.get_doc("Workflow", workflow) + current_state = self.parent.get(workflow_doc.workflow_state_field) + roles = frappe.get_roles() + + allowed = any( + state.state == current_state and (not state.allow_edit or state.allow_edit in roles) + for state in workflow_doc.states + ) + + if not allowed: + frappe.throw( + _("You are not allowed to update as per the conditions set in {0} Workflow.").format( + get_link_to_form("Workflow", workflow) + ), + title=_("Insufficient Permissions"), + ) + + def _get_new_child_item(self, item_row) -> "frappe.model.document.Document": + child_doctype = self.parent_doctype + " Item" + return set_order_defaults( + self.parent_doctype, + self.parent_doctype_name, + child_doctype, + self.child_docname, + item_row, + ) + + def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None: + if not flt(new_data.get("qty")) and not self.allow_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")) + ), + title=_("Invalid Qty"), + ) + + qty_limits = { + "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), + "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), + } + + if self.parent_doctype in qty_limits: + qty_field, error_message = qty_limits[self.parent_doctype] + if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): + frappe.throw( + _("Row #{0}:").format(new_data.get("idx")) + error_message, + title=_("Invalid Qty"), + ) + + if self.parent_doctype not in ("Quotation", "Supplier Quotation"): + return + + items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items + if not items_map: + return + + qty_to_check = items_map.get(child_item.name) + if not qty_to_check: + return + + if not rate_unchanged: + frappe.throw( + _( + "Cannot update rate as item {0} is already ordered or purchased against this quotation" + ).format(frappe.bold(new_data.get("item_code"))) + ) + + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + + def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None: + if is_new: + if not new_data.get("fg_item"): + frappe.throw( + _("Finished Good Item is not specified for service item {0}").format( + new_data["item_code"] + ) + ) + + is_sub_contracted_item, default_bom = frappe.db.get_value( + "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"] + ) + + if not is_sub_contracted_item: + frappe.throw( + _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"]) + ) + elif not default_bom: + frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"])) + + if not new_data.get("fg_item_qty"): + frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) + + +@frappe.whitelist() +def update_child_qty_rate( + parent_doctype: str, trans_items: str | list, parent_doctype_name: str, child_docname: str = "items" +) -> None: + ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) + + +def set_order_defaults( + parent_doctype: str, + parent_doctype_name: str, + child_doctype: str, + child_docname: str, + trans_item: dict, +) -> "frappe.model.document.Document": + """Return a new child item populated with item master defaults.""" + from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map + + p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) + child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) + item = frappe.get_doc("Item", trans_item.get("item_code")) + + for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): + child_item.update({field: item.get(field)}) + + date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" + child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) + child_item.stock_uom = item.stock_uom + child_item.uom = trans_item.get("uom") or item.stock_uom + child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) + conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) + child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor + child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + child_item.base_rate = 1 + child_item.base_amount = 1 + + set_child_tax_template_and_map(item, child_item, p_doc) + add_taxes_from_tax_template(child_item, p_doc) + return child_item + + +def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: + """Return the warehouse picked in the Update Items dialog, else the configured default. + + Validates whichever warehouse was resolved, since a submitted parent skips validate(). + """ + warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True) + + if not warehouse: + if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): + frappe.throw( + _( + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + ).format(frappe.bold(item.item_code)) + ) + return None + + validate_warehouse_company(warehouse, p_doc.company) + validate_disabled_warehouse(warehouse) + is_group_warehouse(warehouse) + return warehouse + + +def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: + """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" + if child_doctype == "Sales Order Item": + return True + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) + + return False + + +def validate_child_on_delete(row, parent, ordered_item=None) -> None: + """Raise if a partially transacted child item is being deleted.""" + if parent.doctype == "Sales Order": + if flt(row.delivered_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been delivered").format( + row.idx, row.item_code + ) + ) + if flt(row.work_order_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( + row.idx, row.item_code + ) + ) + if flt(row.ordered_qty): + frappe.throw( + _( + "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." + ).format(row.idx, row.item_code) + ) + + if parent.doctype == "Purchase Order" and flt(row.received_qty): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been received").format( + row.idx, row.item_code + ) + ) + + if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) + ) + + if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) + + +def update_bin_on_delete(row, doctype: str) -> None: + """Update bin quantities after a child item row is deleted.""" + from erpnext.stock.stock_balance import ( + get_indented_qty, + get_ordered_qty, + get_reserved_qty, + update_bin_qty, + ) + + qty_dict = {} + + if doctype == "Sales Order": + qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) + else: + if row.material_request_item: + qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) + qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) + + if row.warehouse: + update_bin_qty(row.item_code, row.warehouse, qty_dict) + + +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: + """Delete child rows not present in data; return True if any were removed.""" + updated_item_names = [d.get("docname") for d in data] + deleted_children = [item for item in parent.items if item.name not in updated_item_names] + + for d in deleted_children: + validate_child_on_delete(d, parent, ordered_item) + d.flags.ignore_permissions = True + d.cancel() + d.delete() + + if parent.doctype == "Purchase Order": + parent.update_ordered_qty_in_so_for_removed_items(deleted_children) + + if parent.doctype not in ("Quotation", "Supplier Quotation"): + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) + + return bool(deleted_children) + + +def get_allow_zero_qty(parent_doctype: str) -> bool: + if parent_doctype == "Sales Order": + return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False + if parent_doctype == "Purchase Order": + return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False + return False + + +def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: + prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) + prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) + prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) + prev_con_fac = flt(child_item.get("conversion_factor")) + new_con_fac = flt(new_data.get("conversion_factor")) + + if parent_doctype == "Sales Order": + prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") + elif parent_doctype == "Purchase Order": + prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") + else: + prev_date, new_date = None, None + + if parent_doctype in ("Quotation", "Supplier Quotation"): + date_unchanged = False + else: + prev_date = getdate(prev_date) if prev_date else None + new_date = getdate(new_date) if new_date else None + date_unchanged = prev_date == new_date + + return frappe._dict( + rate_unchanged=prev_rate == new_rate, + qty_unchanged=prev_qty == new_qty, + fg_qty_unchanged=prev_fg_qty == new_fg_qty, + uom_unchanged=child_item.get("uom") == new_data.get("uom"), + conversion_factor_unchanged=prev_con_fac == new_con_fac, + date_unchanged=date_unchanged, + description_unchanged=child_item.get("description") == new_data.get("description"), + ) + + +def is_child_item_unchanged(change_state: frappe._dict) -> bool: + return ( + change_state.rate_unchanged + and change_state.qty_unchanged + and change_state.fg_qty_unchanged + and change_state.conversion_factor_unchanged + and change_state.uom_unchanged + and change_state.date_unchanged + and change_state.description_unchanged + ) + + +def update_child_item_rate_and_discount( + parent_doctype: str, + child_item, + new_data, + allow_zero_qty: bool, + rate_unchanged: bool | None = None, +) -> None: + rate_precision = child_item.precision("rate") or 2 + qty_precision = child_item.precision("qty") or 2 + + if rate_unchanged is None: + rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate")) + + if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: + frappe.throw(_("Rate of '{0}' items cannot be changed").format(frappe.bold(_("Unit Price")))) + + row_rate = flt(new_data.get("rate"), rate_precision) + + if parent_doctype in ("Purchase Order", "Sales Order"): + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(new_data.get("qty"), qty_precision), rate_precision + ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + + child_item.rate = row_rate + + if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate): + return + + if flt(child_item.rate) > flt(child_item.price_list_rate): + child_item.discount_percentage = 0 + child_item.discount_amount = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = child_item.price_list_rate + child_item.discount_percentage = 0 + child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate) + + +def update_child_item_uom_and_weight(child_item, new_data) -> None: + conv_fac_precision = child_item.precision("conversion_factor") or 2 + + if new_data.get("conversion_factor"): + if child_item.stock_uom == child_item.uom: + child_item.conversion_factor = 1 + else: + child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) + + if new_data.get("uom"): + child_item.uom = new_data.get("uom") + conversion_factor = flt( + get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") + ) + child_item.conversion_factor = ( + flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor + ) + + if child_item.get("weight_per_unit"): + child_item.total_weight = flt( + child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, + child_item.precision("total_weight"), + ) + + +def check_if_child_table_updated( + child_table_before_update, child_table_after_update, fields_to_check +) -> bool: + """Return True if any accounting-relevant field changed in a child table.""" + fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] + + for index, item in enumerate(child_table_before_update): + for field in fields_to_check: + if child_table_after_update[index].get(field) != item.get(field): + return True + + return False diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index ac5ec434b76..dd899a14588 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -211,6 +211,7 @@ class TestPurchaseOrder(FrappeTestCase): po.load_from_db() existing_ordered_qty = get_ordered_qty() + existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC") first_item_of_po = po.get("items")[0] trans_item = json.dumps( @@ -221,16 +222,62 @@ class TestPurchaseOrder(FrappeTestCase): "qty": first_item_of_po.qty, "docname": first_item_of_po.name, }, - {"item_code": "_Test Item", "rate": 200, "qty": 7}, + {"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"}, ] ) update_child_qty_rate("Purchase Order", trans_item, po.name) po.reload() self.assertEqual(len(po.get("items")), 2) + self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC") self.assertEqual(po.status, "To Receive and Bill") - # ordered qty should increase on row addition - self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7) + # ordered qty should increase on row addition, in the warehouse passed for the new row + self.assertEqual(get_ordered_qty(), existing_ordered_qty) + self.assertEqual( + get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"), + existing_ordered_qty_in_new_warehouse + 7, + ) + + def test_update_child_adding_new_item_without_any_default_warehouse(self): + stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name + service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name + + po = create_purchase_order(do_not_save=1) + po.save() + po.submit() + first_item_of_po = po.get("items")[0] + + company_default = frappe.db.get_value("Company", po.company, "default_warehouse") + frappe.db.set_value("Company", po.company, "default_warehouse", None) + self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default) + + def get_trans_items(item_code): + return json.dumps( + [ + { + "item_code": first_item_of_po.item_code, + "rate": first_item_of_po.rate, + "qty": first_item_of_po.qty, + "docname": first_item_of_po.name, + }, + {"item_code": item_code, "rate": 200, "qty": 7}, + ] + ) + + self.assertRaisesRegex( + frappe.ValidationError, + "Cannot find a default warehouse", + update_child_qty_rate, + "Purchase Order", + get_trans_items(stock_item), + po.name, + ) + + update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name) + + po.reload() + self.assertEqual(po.get("items")[-1].item_code, service_item) + self.assertFalse(po.get("items")[-1].warehouse) def test_update_child_removing_item(self): po = create_purchase_order(do_not_save=1) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 8746aa822aa..47143d6cad1 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -648,6 +648,7 @@ erpnext.utils.update_child_items = function (opts) { qty: d.qty, rate: d.rate, uom: d.uom, + warehouse: d.warehouse, fg_item: d.fg_item, fg_item_qty: d.fg_item_qty, }; @@ -727,8 +728,21 @@ erpnext.utils.update_child_items = function (opts) { }, callback: function (r) { if (r.message) { +<<<<<<< HEAD const { qty, price_list_rate: rate, uom, conversion_factor, bom_no } = r.message; +======= + const { + qty, + price_list_rate: rate, + uom, + conversion_factor, + item_name, + bom_no, + description, + warehouse, + } = r.message; +>>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) const row = dialog.fields_dict.trans_items.df.data.find( (doc) => doc.idx == me.doc.idx ); @@ -739,6 +753,11 @@ erpnext.utils.update_child_items = function (opts) { qty: me.doc.qty || qty, rate: me.doc.rate || rate, bom_no: bom_no, +<<<<<<< HEAD +======= + description: me.doc.description || description, + warehouse: me.doc.docname ? me.doc.warehouse : warehouse, +>>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) }); dialog.fields_dict.trans_items.grid.refresh(); } @@ -812,11 +831,38 @@ erpnext.utils.update_child_items = function (opts) { }); } +<<<<<<< HEAD if ( frm.doc.doctype == "Purchase Order" && frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow ) { +======= + const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse"); + if (warehouse_df) { + fields.splice(3, 0, { + fieldtype: "Link", + fieldname: "warehouse", + options: "Warehouse", + in_list_view: 1, + label: __(warehouse_df.label), + // only new rows may set it, existing rows would leave their + // reserved qty stranded in the previous warehouse's bin + read_only_depends_on: "eval:doc.docname", + get_query: () => { + return { + filters: { + company: frm.doc.company, + is_group: 0, + disabled: 0, + }, + }; + }, + }); + } + + if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) { +>>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) fields.push( { fieldtype: "Link", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 7a796d090ff..6e38ae6e116 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -33,6 +33,11 @@ from erpnext.selling.doctype.sales_order.sales_order import ( from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_bin_details +<<<<<<< HEAD +======= +from erpnext.stock.utils import InvalidWarehouseCompany +from erpnext.tests.utils import ERPNextTestSuite +>>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) class TestSalesOrder(AccountsTestMixin, FrappeTestCase): @@ -558,6 +563,116 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(updated_total, prev_total + 1400) self.assertNotEqual(updated_total_in_words, prev_total_in_words) + def test_update_child_adding_new_item_with_warehouse(self): + so = make_sales_order(item_code="_Test Item", qty=4) + + first_item_of_so = so.get("items")[0] + self.assertNotEqual(first_item_of_so.warehouse, "_Test Warehouse 2 - _TC") + + def get_trans_item(warehouse): + return json.dumps( + [ + { + "item_code": first_item_of_so.item_code, + "rate": first_item_of_so.rate, + "qty": first_item_of_so.qty, + "docname": first_item_of_so.name, + "warehouse": warehouse, + }, + {"item_code": "_Test Item 2", "rate": 200, "qty": 7, "warehouse": warehouse}, + ] + ) + + self.assertRaises( + InvalidWarehouseCompany, + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Warehouse 2 - _TC1"), + so.name, + ) + + self.assertRaisesRegex( + frappe.ValidationError, + "Group node warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Warehouse Group - _TC"), + so.name, + ) + + if not frappe.db.exists("Warehouse", "_Test Disabled Warehouse - _TC"): + frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "_Test Disabled Warehouse", + "company": "_Test Company", + "disabled": 1, + } + ).insert() + + self.assertRaisesRegex( + frappe.ValidationError, + "Disabled Warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_item("_Test Disabled Warehouse - _TC"), + so.name, + ) + + update_child_qty_rate("Sales Order", get_trans_item("_Test Warehouse 2 - _TC"), so.name) + + so.reload() + # the new row picks up the warehouse selected in the dialog + self.assertEqual(so.get("items")[-1].item_code, "_Test Item 2") + self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC") + # existing rows keep theirs, so their reserved qty stays in the same bin + self.assertEqual(so.get("items")[0].warehouse, first_item_of_so.warehouse) + + def test_update_child_adding_new_item_without_any_default_warehouse(self): + item_code = make_item("_Test Item Without Default Warehouse", {"is_stock_item": 1}).name + so = make_sales_order(item_code="_Test Item", qty=4) + existing_item = so.get("items")[0] + + # a company gets a default warehouse when its warehouses are created + company_default = frappe.db.get_value("Company", so.company, "default_warehouse") + frappe.db.set_value("Company", so.company, "default_warehouse", None) + self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default) + + def get_trans_items(warehouse=None): + new_row = {"item_code": item_code, "rate": 200, "qty": 7} + if warehouse: + new_row["warehouse"] = warehouse + + return json.dumps( + [ + { + "item_code": existing_item.item_code, + "rate": existing_item.rate, + "qty": existing_item.qty, + "docname": existing_item.name, + }, + new_row, + ] + ) + + # no default in the Item Master, Item Group, Brand or Company + self.assertRaisesRegex( + frappe.ValidationError, + "Cannot find a default warehouse", + update_child_qty_rate, + "Sales Order", + get_trans_items(), + so.name, + ) + + update_child_qty_rate("Sales Order", get_trans_items("_Test Warehouse - _TC"), so.name) + + so.reload() + self.assertEqual(len(so.get("items")), 2) + self.assertEqual(so.get("items")[0].warehouse, existing_item.warehouse) + self.assertEqual(so.get("items")[-1].item_code, item_code) + self.assertEqual(so.get("items")[-1].warehouse, "_Test Warehouse - _TC") + def test_update_child_removing_item(self): so = make_sales_order(**{"item_list": [{"item_code": "_Test Item", "qty": 5, "rate": 1000}]}) create_dn_against_so(so.name, 2) From 7d9abee6b4744b45fb84640bf7b322e6b0de7493 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:50:44 +0000 Subject: [PATCH 24/60] refactor(accounts)!: rework Purchase Invoice hold actions and enforce them on Journal Entry (backport #57825) (#57867) Co-authored-by: diptanilsaha --- .../doctype/journal_entry/journal_entry.py | 19 ++- .../journal_entry/test_journal_entry.py | 65 +++++++- .../purchase_invoice/purchase_invoice.js | 26 ++- .../purchase_invoice/purchase_invoice.json | 3 +- .../purchase_invoice/purchase_invoice.py | 70 ++++---- .../purchase_invoice/test_purchase_invoice.py | 154 +++++++++++++++++- 6 files changed, 290 insertions(+), 47 deletions(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 762585601e5..5337baed358 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _, msgprint, scrub from frappe.core.doctype.submission_queue.submission_queue import queue_submission -from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate +from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts @@ -798,6 +798,23 @@ class JournalEntry(AccountsController): ) ) + if reference_type == "Purchase Invoice": + on_hold, release_date = frappe.db.get_value( + reference_type, reference_name, ["on_hold", "release_date"] + ) + + if not on_hold or (release_date and getdate(release_date) <= getdate(nowdate())): + continue + + msg = ( + _("{0} {1} is blocked and on hold until {2}.").format( + reference_type, reference_name, release_date + ) + if release_date + else _("{0} {1} is blocked.").format(reference_type, reference_name) + ) + frappe.throw(msg) + def set_against_account(self): accounts_debited, accounts_credited = [], [] if self.voucher_type in ("Deferred Revenue", "Deferred Expense"): diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py index f8fde0b54bc..62e74d02033 100644 --- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py @@ -6,7 +6,7 @@ import unittest import frappe from frappe.tests.utils import change_settings -from frappe.utils import flt, nowdate +from frappe.utils import add_days, flt, nowdate from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction @@ -602,6 +602,69 @@ class TestJournalEntry(unittest.TestCase): jv.save() self.assertRaises(frappe.ValidationError, jv.submit) + def make_jv_against_purchase_invoice(self, invoice, amount=100): + jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False) + jv.accounts[0].party_type = "Supplier" + jv.accounts[0].party = invoice.supplier + jv.accounts[0].reference_type = "Purchase Invoice" + jv.accounts[0].reference_name = invoice.name + return jv + + def test_jv_against_purchase_invoice_respects_hold_state(self): + """Payment can be booked against a Purchase Invoice only while it is not on hold.""" + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + + release_date = add_days(nowdate(), 10) + + def never_held(): + return make_purchase_invoice() + + def held_until_a_future_date(): + invoice = make_purchase_invoice() + invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date) + return invoice + + def held_without_a_release_date(): + invoice = make_purchase_invoice() + invoice.block_invoice(hold_comment="Under dispute") + return invoice + + def held_until_a_date_that_has_passed(): + invoice = held_until_a_future_date() + frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1)) + return invoice + + def unblocked_again(): + invoice = held_until_a_future_date() + invoice.unblock_invoice() + return invoice + + for build_invoice in (held_until_a_future_date, held_without_a_release_date): + with self.subTest(build_invoice.__name__): + jv = self.make_jv_against_purchase_invoice(build_invoice()) + self.assertRaisesRegex(frappe.ValidationError, "is blocked", jv.insert) + + for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again): + with self.subTest(build_invoice.__name__): + invoice = build_invoice() + jv = self.make_jv_against_purchase_invoice(invoice) + jv.insert() + self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice") + + def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self): + """A Sales Invoice has no hold state, so the check must skip it rather than fail.""" + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + invoice = create_sales_invoice(rate=500) + jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False) + jv.accounts[1].party_type = "Customer" + jv.accounts[1].party = "_Test Customer" + jv.accounts[1].reference_type = "Sales Invoice" + jv.accounts[1].reference_name = invoice.name + jv.insert() + + self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice") + def make_journal_entry( account1, diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 195b29e38c6..cb7f9d6af76 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -237,10 +237,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. unblock_invoice() { const me = this; - frappe.call({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice", - args: { name: me.frm.doc.name }, - callback: (r) => me.frm.reload_doc(), + me.frm.call("unblock_invoice", null, () => { + me.frm.reload_doc(); }); } @@ -291,15 +289,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. this.dialog.set_primary_action(__("Save"), function () { const dialog_data = me.dialog.get_values(); - frappe.call({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice", - args: { - name: me.frm.doc.name, + me.frm.call( + "block_invoice", + { hold_comment: dialog_data.hold_comment, release_date: dialog_data.release_date, }, - callback: (r) => me.frm.reload_doc(), - }); + () => { + me.frm.reload_doc(); + } + ); me.dialog.hide(); }); @@ -338,10 +337,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } set_release_date(data) { - return frappe.call({ - method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date", - args: data, - callback: (r) => this.frm.reload_doc(), + const me = this; + return me.frm.call("change_release_date", { release_date: data.release_date }, () => { + me.frm.reload_doc(); }); } diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json index 8e9da9baa9d..058b8c8613b 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.json @@ -352,6 +352,7 @@ { "collapsible": 1, "collapsible_depends_on": "eval:doc.on_hold", + "depends_on": "eval:doc.on_hold", "fieldname": "sb_14", "fieldtype": "Section Break", "label": "Hold Invoice" @@ -1662,7 +1663,7 @@ "idx": 204, "is_submittable": 1, "links": [], - "modified": "2026-07-12 23:54:21.263951", + "modified": "2026-08-05 15:40:16.519774", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index f92252df2a7..afc08c0e4d6 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -8,7 +8,7 @@ import frappe from frappe import _, qb, throw from frappe.model.mapper import get_mapped_doc from frappe.query_builder.functions import Sum -from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate +from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.deferred_revenue import validate_service_stop_date @@ -299,6 +299,9 @@ class PurchaseInvoice(BuyingController): self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse") self.set_percentage_received() + if self.on_hold: + self.validate_invoice_hold() + def set_percentage_received(self): total_billed_qty = 0.0 total_received_qty = 0.0 @@ -310,6 +313,13 @@ class PurchaseInvoice(BuyingController): if total_billed_qty and total_received_qty: self.per_received = total_received_qty / total_billed_qty * 100 + def validate_invoice_hold(self): + if self.is_return: + frappe.throw(_("Return Purchase Invoice cannot be held.")) + + if self.docstatus < 1: + frappe.throw(_("Purchase Invoice can be held after submitting.")) + def validate_release_date(self): if self.release_date and getdate(nowdate()) >= getdate(self.release_date): frappe.throw(_("Release date must be in the future")) @@ -1855,14 +1865,38 @@ class PurchaseInvoice(BuyingController): def on_recurring(self, reference_doc, auto_repeat_doc): self.due_date = None - def block_invoice(self, hold_comment=None, release_date=None): - self.db_set("on_hold", 1) - self.db_set("hold_comment", cstr(hold_comment)) + @frappe.whitelist(methods=["POST"]) + def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None): + self.check_permission("write") + self.on_hold = 1 + self.release_date = release_date + self.validate_block_invoice() + + self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date}) + + @frappe.whitelist(methods=["POST"]) + def unblock_invoice(self): + self.check_permission("write") + self.db_set({"on_hold": 0, "release_date": None}) + + @frappe.whitelist(methods=["POST"]) + def change_release_date(self, release_date: DateTimeLikeObject | None = None): + self.check_permission("write") + + if not self.on_hold: + frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date.")) + + self.release_date = release_date + self.validate_block_invoice() + self.db_set("release_date", release_date) - def unblock_invoice(self): - self.db_set("on_hold", 0) - self.db_set("release_date", None) + def validate_block_invoice(self): + self.validate_invoice_hold() + if self.outstanding_amount <= 0: + frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held.")) + + self.validate_release_date() def set_tax_withholding(self): self.set("advance_tax", []) @@ -2082,28 +2116,6 @@ def make_stock_entry(source_name, target_doc=None): return doc -@frappe.whitelist() -def change_release_date(name, release_date=None): - if frappe.db.exists("Purchase Invoice", name): - pi = frappe.get_doc("Purchase Invoice", name) - pi.check_permission() - pi.db_set("release_date", release_date) - - -@frappe.whitelist() -def unblock_invoice(name): - if frappe.db.exists("Purchase Invoice", name): - pi = frappe.get_doc("Purchase Invoice", name) - pi.unblock_invoice() - - -@frappe.whitelist() -def block_invoice(name, release_date, hold_comment=None): - if frappe.db.exists("Purchase Invoice", name): - pi = frappe.get_doc("Purchase Invoice", name) - pi.block_invoice(hold_comment, release_date) - - @frappe.whitelist() def make_inter_company_sales_invoice(source_name, target_doc=None): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 5aa2faed1a1..ae9b8442c34 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -287,14 +287,166 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): def test_purchase_invoice_explicit_block(self): pi = make_purchase_invoice() - pi.block_invoice() + release_date = add_days(nowdate(), 10) + + pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date) self.assertEqual(pi.on_hold, 1) + on_hold, hold_comment, saved_release_date = frappe.db.get_value( + "Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"] + ) + self.assertEqual(on_hold, 1) + self.assertEqual(hold_comment, "Waiting for the goods") + self.assertEqual(getdate(saved_release_date), getdate(release_date)) + pi.unblock_invoice() self.assertEqual(pi.on_hold, 0) + on_hold, saved_release_date = frappe.db.get_value( + "Purchase Invoice", pi.name, ["on_hold", "release_date"] + ) + self.assertEqual(on_hold, 0) + self.assertIsNone(saved_release_date) + + def test_purchase_invoice_cannot_be_held_before_submission(self): + pi = make_purchase_invoice(do_not_save=True) + pi.on_hold = 1 + + self.assertRaises(frappe.ValidationError, pi.save) + + pi.on_hold = 0 + pi.save() + pi.submit() + + pi.block_invoice() + self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1) + + def test_return_purchase_invoice_cannot_be_held(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + pi = make_purchase_invoice() + + return_pi = make_return_doc(pi.doctype, pi.name) + return_pi.on_hold = 1 + self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save) + + return_pi.on_hold = 0 + return_pi.save() + return_pi.submit() + + self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice) + + def test_return_purchase_invoice_is_not_affected_by_hold_validations(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + pi = make_purchase_invoice() + + # a return has a negative outstanding amount, which must not be mistaken + # for an invalid hold on a document that was never held + return_pi = make_return_doc(pi.doctype, pi.name) + return_pi.save() + return_pi.submit() + + self.assertEqual(return_pi.docstatus, 1) + self.assertEqual(return_pi.on_hold, 0) + self.assertLess(return_pi.outstanding_amount, 0) + + def test_settled_purchase_invoice_cannot_be_held(self): + pi = make_purchase_invoice() + + pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC") + pe.reference_no = "1" + pe.reference_date = nowdate() + pe.save() + pe.submit() + + pi.reload() + self.assertEqual(pi.outstanding_amount, 0) + + self.assertRaises(frappe.ValidationError, pi.block_invoice) + self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0) + + def test_release_date_of_held_invoice_must_be_in_future(self): + pi = make_purchase_invoice() + + self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1)) + self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate()) + + def test_rejected_hold_does_not_partially_update_invoice(self): + pi = make_purchase_invoice() + + self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1)) + + pi.reload() + self.assertEqual(pi.on_hold, 0) + self.assertIsNone(pi.release_date) + + def test_change_release_date_of_held_invoice(self): + pi = make_purchase_invoice() + pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10)) + + new_release_date = add_days(nowdate(), 20) + pi.change_release_date(new_release_date) + + self.assertEqual( + getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")), + getdate(new_release_date), + ) + + self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1)) + + def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self): + pi = make_purchase_invoice() + + self.assertRaisesRegex( + frappe.ValidationError, + "Invoice is not blocked", + pi.change_release_date, + add_days(nowdate(), 10), + ) + + self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")) + + def test_hold_methods_are_whitelisted_document_methods(self): + import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module + + pi = frappe.new_doc("Purchase Invoice") + + for method in ("block_invoice", "unblock_invoice", "change_release_date"): + # raises if the method is not whitelisted for client side calls + pi.is_whitelisted(method) + + self.assertFalse( + hasattr(purchase_invoice_module, method), + f"{method} should only be exposed as a document method", + ) + + def test_hold_methods_require_write_permission(self): + pi = make_purchase_invoice() + user = "test_pi_hold_permission@example.com" + + if not frappe.db.exists("User", user): + frappe.get_doc( + { + "doctype": "User", + "email": user, + "first_name": "Test PI Hold", + "roles": [{"role": "Employee"}], + } + ).insert(ignore_permissions=True) + + frappe.set_user(user) + try: + self.assertRaises(frappe.PermissionError, pi.block_invoice) + self.assertRaises(frappe.PermissionError, pi.unblock_invoice) + self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10)) + finally: + frappe.set_user("Administrator") + + self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0) + def test_gl_entries_with_perpetual_inventory_against_pr(self): pr = make_purchase_receipt( company="_Test Company with perpetual inventory", From 262fbc52d887c9efd06973ef6d86f714e5848e77 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sun, 9 Aug 2026 11:25:12 +0530 Subject: [PATCH 25/60] fix: tolerate floating-point drift in sales team allocated percentage the total of allocated_percentage was compared to 100 with exact float equality, so a correct allocation could be rejected when the sum drifts in binary floating point (10.0 + 58.02 + 31.98 -> 100.00000000000001). round the total to the field precision before comparing, in both SellingController.calculate_contribution and Customer.validate. (cherry picked from commit f7b277582940cbb5f7a427ef8250b18db773004b) --- erpnext/controllers/selling_controller.py | 2 +- erpnext/selling/doctype/customer/customer.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 5655fea9915..f1e3baebc51 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -236,7 +236,7 @@ class SellingController(StockController): total += sales_person.allocated_percentage - if sales_team and total != 100.0: + if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0: throw(_("Total allocated percentage for sales team should be 100")) def validate_sales_team(self, sales_team): diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 4d21ff94d3e..4df15cac65e 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -165,7 +165,8 @@ class Customer(TransactionBase): self.loyalty_program_tier = customer.loyalty_program_tier if self.sales_team: - if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100: + total = sum(flt(member.allocated_percentage) for member in self.sales_team) + if flt(total, self.precision("allocated_percentage", "sales_team")) != 100: frappe.throw(_("Total contribution percentage should be equal to 100")) @frappe.whitelist() From 1a896090448e79296c8e1170b5e8353c70e2e7d1 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sun, 9 Aug 2026 11:25:24 +0530 Subject: [PATCH 26/60] test: sales team allocation totalling 100 in floating point covers the case where the percentages are correct but the accumulated sum is 100.00000000000001. two rows can never drift, since the second reconstructs exactly as 100 - first, so the case needs three rows. (cherry picked from commit 4afba94d1c3dffec6cb789f69f537883b88dc32d) # Conflicts: # erpnext/selling/doctype/sales_order/test_sales_order.py --- .../doctype/sales_order/test_sales_order.py | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 7a796d090ff..dcf99b922a5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2646,6 +2646,282 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1) self.assertRaises(frappe.ValidationError, so.save) +<<<<<<< HEAD +======= + @ERPNextTestSuite.change_settings( + "Stock Settings", {"enable_stock_reservation": 1, "use_serial_batch_fields": 0} + ) + def test_product_bundle_reservation(self): + pb_item = make_item("Product Bundle Item", {"is_stock_item": 0}) + simple_item = make_item("Simple Item", {"is_stock_item": 1}) + sb_item = make_item( + "Serial Batch Item", + { + "is_stock_item": 1, + "has_serial_no": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BAT-TSBIFRM-.#####", + "serial_no_series": "SN-TSBIFRM-.#####", + }, + ) + make_product_bundle(pb_item.name, [simple_item.name, sb_item.name]) + + warehouse = "_Test Warehouse - _TC" + + make_stock_entry( + item_code=simple_item.name, + target=warehouse, + qty=10, + ) + + # two different stock entries on purpose to get two batches + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + make_stock_entry( + item_code=sb_item.name, + target=warehouse, + qty=5, + ) + + so = make_sales_order(item_code=pb_item.name, do_not_submit=1) + so.reserve_stock = 1 + for item in so.packed_items: + item.reserve_stock = 1 + so.submit() + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_batch_nos_details, + get_sre_reserved_qty_for_voucher_detail_no, + get_sre_reserved_serial_nos_details, + ) + + for item in so.packed_items: + self.assertEqual( + get_sre_reserved_qty_for_voucher_detail_no(item.item_code, "Sales Order", so.name, item.name), + item.qty, + ) + + sre_serial_nos = list(get_sre_reserved_serial_nos_details(sb_item.name, warehouse).keys()) + sre_batch_nos = list(get_sre_reserved_batch_nos_details(sb_item.name, warehouse).keys()) + + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn.save() + + self.assertTrue(dn.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + dn.items[0].qty = 5 + dn.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", dn.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = dn.company + sabb_doc.save() + dn.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + dn1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + dn1.save() + + self.assertTrue(dn1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(dn1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(dn1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + + dn.cancel() + + # test the same thing with sales invoice as well + + si = make_sales_invoice(so.name) + si.update_stock = 1 + si.save() + + self.assertTrue(si.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle) + batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(sre_serial_nos, serial_nos_in_bundle) + self.assertEqual(sre_batch_nos, batches_in_bundle) + + si.items[0].qty = 5 + si.save() + sabb_doc = frappe.get_doc("Serial and Batch Bundle", si.packed_items[1].serial_and_batch_bundle) + sabb_doc.entries = sabb_doc.entries[:5] + sabb_doc.company = si.company + sabb_doc.save() + si.submit() + + serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) + batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) + + si1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) + si1.save() + + self.assertTrue(si1.packed_items[1].serial_and_batch_bundle) + + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos + + serial_nos_in_bundle = set(get_serial_nos(si1.packed_items[1].serial_and_batch_bundle)) + batches_in_bundle = set(get_batches_from_bundle(si1.packed_items[1].serial_and_batch_bundle).keys()) + + self.assertEqual(serial_nos, serial_nos_in_bundle) + self.assertEqual(batch_nos, batches_in_bundle) + + def test_sales_team_contribution_follows_grant_commission(self): + """Sales-person allocation tracks the grant-commission-eligible amount, not the gross total. + + The Item "Grant Commission" flag includes an item in both Sales Partner and Sales Person + commission, so each sales person's allocated_amount is a share of + amount_eligible_for_commission rather than net_total. + """ + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 0) + try: + so = make_sales_order( + do_not_save=True, + item_list=[ + {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 100}, + { + "item_code": "_Test FG Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + ], + ) + so.append( + "sales_team", + {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60, "commission_rate": 10}, + ) + so.append( + "sales_team", + {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40, "commission_rate": 0}, + ) + so.save() + + self.assertEqual(so.net_total, 2000) + self.assertEqual(so.amount_eligible_for_commission, 1000) # only the grant_commission item + + first, second = so.sales_team + # allocation follows the eligible amount (1000), not net_total (2000) + self.assertEqual(first.allocated_amount, 600) + self.assertEqual(first.incentives, 60) # 600 * 10% + self.assertEqual(second.allocated_amount, 400) + self.assertEqual(second.incentives, 0) + finally: + # grant_commission defaults to 1 for both items; restore + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 1) + + def test_sales_team_allocated_percentage_must_total_100(self): + with self.subTest("partial allocation is rejected"): + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) + self.assertRaises(frappe.ValidationError, so.save) + + with self.subTest("allocation totalling 100 is accepted"): + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) + so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40}) + so.save() + self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100) + + with self.subTest("floating-point drift in the total is tolerated"): + # 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point + so = make_sales_order(do_not_save=True) + for sales_person, percentage in ( + ("_Test Sales Person", 10.0), + ("_Test Sales Person 1", 58.02), + ("_Test Sales Person 2", 31.98), + ): + so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage}) + so.save() + + def test_sales_team_disabled_sales_person_rejected(self): + frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0) + try: + so = make_sales_order(do_not_save=True) + so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 100}) + self.assertRaises(frappe.ValidationError, so.save) + finally: + frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 1) + + def test_sales_partner_commission(self): + """Sales Partner commission: total_commission = amount_eligible_for_commission * rate / 100.""" + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.save() + + self.assertEqual(so.amount_eligible_for_commission, 1000) + self.assertEqual(so.total_commission, 70) # 1000 * 7% + + with self.subTest("commission rate above 100 is rejected"): + so.commission_rate = 101 + self.assertRaises(frappe.ValidationError, so.save) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + + def test_commission_fields_not_copied_on_duplicate(self): + """Commission rate/amount fields are no_copy; only the sales partner carries to a copy.""" + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.save() + self.assertEqual(so.total_commission, 70) + + # ignore_no_copy=False mirrors UI "Duplicate"/amend, which honour no_copy + duplicate = frappe.copy_doc(so, ignore_no_copy=False) + self.assertEqual(duplicate.sales_partner, "_Test Sales Partner India - 1") + self.assertFalse(duplicate.commission_rate) + self.assertFalse(duplicate.total_commission) + self.assertFalse(duplicate.amount_eligible_for_commission) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + + def test_commission_rate_carried_through_mapper(self): + """commission_rate is no_copy, but Make Delivery Note / Sales Invoice still carries it.""" + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice + + original = frappe.db.get_value("Item", "_Test Item", "grant_commission") + frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) + try: + so = make_sales_order(qty=10, rate=100, do_not_save=True) + so.sales_partner = "_Test Sales Partner India - 1" + so.commission_rate = 7 + so.submit() + + # carried to the mapped (unsaved) documents even though the field is no_copy + self.assertEqual(make_delivery_note(so.name).commission_rate, 7) + self.assertEqual(make_sales_invoice(so.name).commission_rate, 7) + finally: + frappe.db.set_value("Item", "_Test Item", "grant_commission", original) + +>>>>>>> 4afba94d1c (test: sales team allocation totalling 100 in floating point) def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")): From c23aa72dffed05be5351b475b5e8a12e399c7d42 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 12:41:55 +0530 Subject: [PATCH 27/60] chore: resolve conflict --- .../accounts/services/child_item_update.py | 618 ------------------ erpnext/controllers/accounts_controller.py | 48 +- erpnext/public/js/utils.js | 25 +- .../doctype/sales_order/test_sales_order.py | 4 - 4 files changed, 44 insertions(+), 651 deletions(-) delete mode 100644 erpnext/accounts/services/child_item_update.py diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py deleted file mode 100644 index d66c5621f7a..00000000000 --- a/erpnext/accounts/services/child_item_update.py +++ /dev/null @@ -1,618 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API.""" - -import frappe -from frappe import _ -from frappe.model.workflow import get_workflow_name -from frappe.utils import flt, get_link_to_form, getdate - -from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions -from erpnext.buying.utils import update_last_purchase_rate -from erpnext.stock.doctype.packed_item.packed_item import make_packing_list -from erpnext.stock.get_item_details import ( - get_bin_details, - get_conversion_factor, - get_item_warehouse_, -) -from erpnext.stock.utils import ( - is_group_warehouse, - validate_disabled_warehouse, - validate_warehouse_company, -) - - -class ChildItemUpdater: - """Validates and applies item-level edits on submitted orders and quotations.""" - - def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"): - self.parent_doctype = parent_doctype - self.parent_doctype_name = parent_doctype_name - self.child_docname = child_docname - self.parent = frappe.get_doc(parent_doctype, parent_doctype_name) - self.allow_zero_qty = get_allow_zero_qty(parent_doctype) - self._ordered_items: dict | None = None - self._purchased_items: dict | None = None - - def update(self, trans_items: str | list) -> None: - """Process item additions, edits, and deletions from trans_items JSON.""" - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items - from erpnext.selling.doctype.quotation.mapper import get_ordered_items - - data = frappe.parse_json(trans_items) - any_qty_changed = False - items_added_or_removed = False - any_conversion_factor_changed = False - - self._check_permissions("write") - - if self.parent_doctype == "Quotation": - self._ordered_items = get_ordered_items(self.parent.name) - items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items) - elif self.parent_doctype == "Supplier Quotation": - self._purchased_items = get_purchased_items(self.parent.name) - items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items) - else: - items_added_or_removed |= validate_and_delete_children(self.parent, data) - - for d in data: - new_child_flag = False - rate_unchanged = None - - if not d.get("item_code"): - continue - - if not d.get("docname"): - new_child_flag = True - items_added_or_removed = True - self._check_permissions("create") - child_item = self._get_new_child_item(d) - else: - self._check_permissions("write") - child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname")) - - change_state = get_child_item_change_state(self.parent_doctype, child_item, d) - rate_unchanged = change_state.rate_unchanged - any_conversion_factor_changed |= not change_state.conversion_factor_unchanged - if is_child_item_unchanged(change_state): - continue - - self._validate_quantity_and_rate(child_item, d, rate_unchanged) - - if flt(child_item.get("qty")) != flt(d.get("qty")): - any_qty_changed = True - - if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted: - self._validate_fg_item_for_subcontracting(d, new_child_flag) - child_item.fg_item_qty = flt(d["fg_item_qty"]) - if new_child_flag: - child_item.fg_item = d["fg_item"] - - child_item.qty = flt(d.get("qty")) - child_item.description = d.get("description") - update_child_item_rate_and_discount( - self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged - ) - update_child_item_uom_and_weight(child_item, d) - - if d.get("delivery_date") and self.parent_doctype == "Sales Order": - child_item.delivery_date = d.get("delivery_date") - - if d.get("schedule_date") and self.parent_doctype == "Purchase Order": - child_item.schedule_date = d.get("schedule_date") - - if d.get("bom_no") and self.parent_doctype == "Sales Order": - child_item.bom_no = d.get("bom_no") - - child_item.flags.ignore_validate_update_after_submit = True - if new_child_flag: - self.parent.load_from_db() - child_item.idx = len(self.parent.items) + 1 - child_item.insert() - else: - child_item.save(ignore_permissions=True) - - self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed) - - def _post_update( - self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool - ) -> None: - parent = self.parent - parent.reload() - parent.flags.ignore_validate_update_after_submit = True - parent.set_qty_as_per_stock_uom() - parent.calculate_taxes_and_totals() - parent.set_total_in_words() - - if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: - make_packing_list(parent) - parent.set_gross_profit() - - frappe.get_cached_doc("Authorization Control").validate_approving_authority( - parent.doctype, parent.company, parent.base_grand_total - ) - - if self.parent_doctype != "Supplier Quotation": - from erpnext.accounts.services.payment_schedule import PaymentScheduleService - - PaymentScheduleService(parent).set_payment_schedule() - - if self.parent_doctype == "Purchase Order": - parent.validate_minimum_order_qty() - parent.validate_budget() - if parent.is_against_so(): - parent.update_status_updater() - elif self.parent_doctype == "Sales Order": - parent.check_credit_limit() - - for idx, row in enumerate(parent.get(self.child_docname), start=1): - row.idx = idx - - parent.save() - - if self.parent_doctype == "Purchase Order": - update_last_purchase_rate(parent, is_submit=1) - - if any_qty_changed or items_added_or_removed or any_conversion_factor_changed: - parent.update_prevdoc_status() - - parent.update_requested_qty() - parent.update_ordered_qty() - parent.update_ordered_and_reserved_qty() - parent.update_receiving_percentage() - - if parent.is_subcontracted and not parent.can_update_items(): - frappe.throw( - _( - "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." - ).format(frappe.bold(parent.name)) - ) - - elif self.parent_doctype == "Sales Order": - if parent.is_subcontracted and not parent.can_update_items(): - frappe.throw( - _( - "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order." - ) - ) - parent.validate_selling_price() - parent.validate_for_duplicate_items() - parent.validate_warehouse() - parent.update_reserved_qty() - parent.update_project() - parent.update_prevdoc_status("submit") - parent.update_delivery_status() - - parent.reload() - self._validate_workflow() - - if self.parent_doctype in ("Purchase Order", "Sales Order"): - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() - - parent.validate_uom_is_integer("uom", "qty") - parent.validate_uom_is_integer("stock_uom", "stock_qty") - - if self.parent_doctype == "Sales Order" and not parent.is_subcontracted: - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - cancel_stock_reservation_entries, - has_reserved_stock, - ) - - if has_reserved_stock(parent.doctype, parent.name): - cancel_stock_reservation_entries(parent.doctype, parent.name) - if parent.per_picked == 0: - parent.create_stock_reservation_entries() - - def _check_permissions(self, perm_type: str = "create") -> None: - try: - self.parent.check_permission(perm_type) - except frappe.PermissionError: - actions = {"create": "add", "write": "update"} - frappe.throw( - _("You do not have permissions to {0} items in a {1}.").format( - actions[perm_type], self.parent_doctype - ), - title=_("Insufficient Permissions"), - ) - - def _validate_workflow(self) -> None: - workflow = get_workflow_name(self.parent.doctype) - if not workflow: - return - - workflow_doc = frappe.get_doc("Workflow", workflow) - current_state = self.parent.get(workflow_doc.workflow_state_field) - roles = frappe.get_roles() - - allowed = any( - state.state == current_state and (not state.allow_edit or state.allow_edit in roles) - for state in workflow_doc.states - ) - - if not allowed: - frappe.throw( - _("You are not allowed to update as per the conditions set in {0} Workflow.").format( - get_link_to_form("Workflow", workflow) - ), - title=_("Insufficient Permissions"), - ) - - def _get_new_child_item(self, item_row) -> "frappe.model.document.Document": - child_doctype = self.parent_doctype + " Item" - return set_order_defaults( - self.parent_doctype, - self.parent_doctype_name, - child_doctype, - self.child_docname, - item_row, - ) - - def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None: - if not flt(new_data.get("qty")) and not self.allow_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")) - ), - title=_("Invalid Qty"), - ) - - qty_limits = { - "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")), - "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")), - } - - if self.parent_doctype in qty_limits: - qty_field, error_message = qty_limits[self.parent_doctype] - if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): - frappe.throw( - _("Row #{0}:").format(new_data.get("idx")) + error_message, - title=_("Invalid Qty"), - ) - - if self.parent_doctype not in ("Quotation", "Supplier Quotation"): - return - - items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items - if not items_map: - return - - qty_to_check = items_map.get(child_item.name) - if not qty_to_check: - return - - if not rate_unchanged: - frappe.throw( - _( - "Cannot update rate as item {0} is already ordered or purchased against this quotation" - ).format(frappe.bold(new_data.get("item_code"))) - ) - - if flt(new_data.get("qty")) < qty_to_check: - frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) - - def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None: - if is_new: - if not new_data.get("fg_item"): - frappe.throw( - _("Finished Good Item is not specified for service item {0}").format( - new_data["item_code"] - ) - ) - - is_sub_contracted_item, default_bom = frappe.db.get_value( - "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"] - ) - - if not is_sub_contracted_item: - frappe.throw( - _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"]) - ) - elif not default_bom: - frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"])) - - if not new_data.get("fg_item_qty"): - frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) - - -@frappe.whitelist() -def update_child_qty_rate( - parent_doctype: str, trans_items: str | list, parent_doctype_name: str, child_docname: str = "items" -) -> None: - ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) - - -def set_order_defaults( - parent_doctype: str, - parent_doctype_name: str, - child_doctype: str, - child_docname: str, - trans_item: dict, -) -> "frappe.model.document.Document": - """Return a new child item populated with item master defaults.""" - from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map - - p_doc = frappe.get_doc(parent_doctype, parent_doctype_name) - child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname) - item = frappe.get_doc("Item", trans_item.get("item_code")) - - for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"): - child_item.update({field: item.get(field)}) - - date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date" - child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) - child_item.stock_uom = item.stock_uom - child_item.uom = trans_item.get("uom") or item.stock_uom - child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) - conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) - child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) - - if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): - child_item.base_rate = 1 - child_item.base_amount = 1 - - set_child_tax_template_and_map(item, child_item, p_doc) - add_taxes_from_tax_template(child_item, p_doc) - return child_item - - -def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: - """Return the warehouse picked in the Update Items dialog, else the configured default. - - Validates whichever warehouse was resolved, since a submitted parent skips validate(). - """ - warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True) - - if not warehouse: - if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): - frappe.throw( - _( - "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." - ).format(frappe.bold(item.item_code)) - ) - return None - - validate_warehouse_company(warehouse, p_doc.company) - validate_disabled_warehouse(warehouse) - is_group_warehouse(warehouse) - return warehouse - - -def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: - """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" - if child_doctype == "Sales Order Item": - return True - - if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): - return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) - - return False - - -def validate_child_on_delete(row, parent, ordered_item=None) -> None: - """Raise if a partially transacted child item is being deleted.""" - if parent.doctype == "Sales Order": - if flt(row.delivered_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been delivered").format( - row.idx, row.item_code - ) - ) - if flt(row.work_order_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format( - row.idx, row.item_code - ) - ) - if flt(row.ordered_qty): - frappe.throw( - _( - "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order." - ).format(row.idx, row.item_code) - ) - - if parent.doctype == "Purchase Order" and flt(row.received_qty): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been received").format( - row.idx, row.item_code - ) - ) - - if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code - ) - ) - - if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name): - frappe.throw(_("Cannot delete an item which has been ordered")) - - -def update_bin_on_delete(row, doctype: str) -> None: - """Update bin quantities after a child item row is deleted.""" - from erpnext.stock.stock_balance import ( - get_indented_qty, - get_ordered_qty, - get_reserved_qty, - update_bin_qty, - ) - - qty_dict = {} - - if doctype == "Sales Order": - qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse) - else: - if row.material_request_item: - qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse) - qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse) - - if row.warehouse: - update_bin_qty(row.item_code, row.warehouse, qty_dict) - - -def validate_and_delete_children(parent, data, ordered_item=None) -> bool: - """Delete child rows not present in data; return True if any were removed.""" - updated_item_names = [d.get("docname") for d in data] - deleted_children = [item for item in parent.items if item.name not in updated_item_names] - - for d in deleted_children: - validate_child_on_delete(d, parent, ordered_item) - d.flags.ignore_permissions = True - d.cancel() - d.delete() - - if parent.doctype == "Purchase Order": - parent.update_ordered_qty_in_so_for_removed_items(deleted_children) - - if parent.doctype not in ("Quotation", "Supplier Quotation"): - parent.update_prevdoc_status() - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) - - return bool(deleted_children) - - -def get_allow_zero_qty(parent_doctype: str) -> bool: - if parent_doctype == "Sales Order": - return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False - if parent_doctype == "Purchase Order": - return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False - return False - - -def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict: - prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate")) - prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty")) - prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty")) - prev_con_fac = flt(child_item.get("conversion_factor")) - new_con_fac = flt(new_data.get("conversion_factor")) - - if parent_doctype == "Sales Order": - prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date") - elif parent_doctype == "Purchase Order": - prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date") - else: - prev_date, new_date = None, None - - if parent_doctype in ("Quotation", "Supplier Quotation"): - date_unchanged = False - else: - prev_date = getdate(prev_date) if prev_date else None - new_date = getdate(new_date) if new_date else None - date_unchanged = prev_date == new_date - - return frappe._dict( - rate_unchanged=prev_rate == new_rate, - qty_unchanged=prev_qty == new_qty, - fg_qty_unchanged=prev_fg_qty == new_fg_qty, - uom_unchanged=child_item.get("uom") == new_data.get("uom"), - conversion_factor_unchanged=prev_con_fac == new_con_fac, - date_unchanged=date_unchanged, - description_unchanged=child_item.get("description") == new_data.get("description"), - ) - - -def is_child_item_unchanged(change_state: frappe._dict) -> bool: - return ( - change_state.rate_unchanged - and change_state.qty_unchanged - and change_state.fg_qty_unchanged - and change_state.conversion_factor_unchanged - and change_state.uom_unchanged - and change_state.date_unchanged - and change_state.description_unchanged - ) - - -def update_child_item_rate_and_discount( - parent_doctype: str, - child_item, - new_data, - allow_zero_qty: bool, - rate_unchanged: bool | None = None, -) -> None: - rate_precision = child_item.precision("rate") or 2 - qty_precision = child_item.precision("qty") or 2 - - if rate_unchanged is None: - rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate")) - - if not rate_unchanged and not child_item.get("qty") and allow_zero_qty: - frappe.throw(_("Rate of '{0}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - - row_rate = flt(new_data.get("rate"), rate_precision) - - if parent_doctype in ("Purchase Order", "Sales Order"): - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(new_data.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) - ) - - child_item.rate = row_rate - - if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate): - return - - if flt(child_item.rate) > flt(child_item.price_list_rate): - child_item.discount_percentage = 0 - child_item.discount_amount = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = child_item.price_list_rate - child_item.discount_percentage = 0 - child_item.discount_amount = flt(child_item.rate_with_margin) - flt(child_item.rate) - - -def update_child_item_uom_and_weight(child_item, new_data) -> None: - conv_fac_precision = child_item.precision("conversion_factor") or 2 - - if new_data.get("conversion_factor"): - if child_item.stock_uom == child_item.uom: - child_item.conversion_factor = 1 - else: - child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision) - - if new_data.get("uom"): - child_item.uom = new_data.get("uom") - conversion_factor = flt( - get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor") - ) - child_item.conversion_factor = ( - flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor - ) - - if child_item.get("weight_per_unit"): - child_item.total_weight = flt( - child_item.weight_per_unit * child_item.qty * child_item.conversion_factor, - child_item.precision("total_weight"), - ) - - -def check_if_child_table_updated( - child_table_before_update, child_table_after_update, fields_to_check -) -> bool: - """Return True if any accounting-relevant field changed in a child table.""" - fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"] - - for index, item in enumerate(child_table_before_update): - for field in fields_to_check: - if child_table_after_update[index].get(field) != item.get(field): - return True - - return False diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 7f2afecaf9f..6ee13e7cb1f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -75,6 +75,11 @@ from erpnext.stock.get_item_details import ( get_item_tax_map, get_item_warehouse, ) +from erpnext.stock.utils import ( + is_group_warehouse, + validate_disabled_warehouse, + validate_warehouse_company, +) from erpnext.utilities.regional import temporary_flag from erpnext.utilities.transaction_base import TransactionBase @@ -3722,7 +3727,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)}) child_item.stock_uom = item.stock_uom child_item.uom = trans_item.get("uom") or item.stock_uom - child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) + child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype) conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company"))) @@ -3731,20 +3736,45 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 - if child_doctype == "Sales Order Item": - child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True) - if not child_item.warehouse: - frappe.throw( - _( - "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings." - ).format(frappe.bold(item.item_code)) - ) set_child_tax_template_and_map(item, child_item, p_doc) add_taxes_from_tax_template(child_item, p_doc) return child_item +def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None: + """Return the warehouse picked in the Update Items dialog, else the configured default. + + Validates whichever warehouse was resolved, since a submitted parent skips validate(). + """ + warehouse = trans_item.get("warehouse") or get_item_warehouse(item, p_doc, overwrite_warehouse=True) + + if not warehouse: + if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): + frappe.throw( + _( + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + ).format(frappe.bold(item.item_code)) + ) + return None + + validate_warehouse_company(warehouse, p_doc.company) + validate_disabled_warehouse(warehouse) + is_group_warehouse(warehouse) + return warehouse + + +def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool: + """Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse.""" + if child_doctype == "Sales Order Item": + return True + + if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"): + return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier) + + return False + + def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 47143d6cad1..2f66c0720d8 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -728,21 +728,14 @@ erpnext.utils.update_child_items = function (opts) { }, callback: function (r) { if (r.message) { -<<<<<<< HEAD - const { qty, price_list_rate: rate, uom, conversion_factor, bom_no } = r.message; - -======= const { qty, price_list_rate: rate, uom, conversion_factor, - item_name, bom_no, - description, warehouse, } = r.message; ->>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) const row = dialog.fields_dict.trans_items.df.data.find( (doc) => doc.idx == me.doc.idx ); @@ -753,11 +746,7 @@ erpnext.utils.update_child_items = function (opts) { qty: me.doc.qty || qty, rate: me.doc.rate || rate, bom_no: bom_no, -<<<<<<< HEAD -======= - description: me.doc.description || description, warehouse: me.doc.docname ? me.doc.warehouse : warehouse, ->>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) }); dialog.fields_dict.trans_items.grid.refresh(); } @@ -831,13 +820,6 @@ erpnext.utils.update_child_items = function (opts) { }); } -<<<<<<< HEAD - if ( - frm.doc.doctype == "Purchase Order" && - frm.doc.is_subcontracted && - !frm.doc.is_old_subcontracting_flow - ) { -======= const warehouse_df = child_meta.fields.find((f) => f.fieldname == "warehouse"); if (warehouse_df) { fields.splice(3, 0, { @@ -861,8 +843,11 @@ erpnext.utils.update_child_items = function (opts) { }); } - if (["Purchase Order", "Sales Order"].includes(frm.doc.doctype) && frm.doc.is_subcontracted) { ->>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) + if ( + frm.doc.doctype == "Purchase Order" && + frm.doc.is_subcontracted && + !frm.doc.is_old_subcontracting_flow + ) { fields.push( { fieldtype: "Link", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 6e38ae6e116..ccead71f71e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -33,11 +33,7 @@ from erpnext.selling.doctype.sales_order.sales_order import ( from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.get_item_details import get_bin_details -<<<<<<< HEAD -======= from erpnext.stock.utils import InvalidWarehouseCompany -from erpnext.tests.utils import ERPNextTestSuite ->>>>>>> 55fe269046 (fix: allow selecting a warehouse for new items in the update items dialog (#57876)) class TestSalesOrder(AccountsTestMixin, FrappeTestCase): From 206d291817e835d3e02e5e04dbd2a634ecdac447 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 12:50:38 +0530 Subject: [PATCH 28/60] chore: resolve conflict --- .../doctype/sales_order/test_sales_order.py | 285 +----------------- 1 file changed, 10 insertions(+), 275 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index dcf99b922a5..2bbb043df7e 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -2646,282 +2646,17 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so = make_sales_order(item_code=fg_item, qty=10, rate=50, warehouse=fg_warehouse, do_not_save=1) self.assertRaises(frappe.ValidationError, so.save) -<<<<<<< HEAD -======= - @ERPNextTestSuite.change_settings( - "Stock Settings", {"enable_stock_reservation": 1, "use_serial_batch_fields": 0} - ) - def test_product_bundle_reservation(self): - pb_item = make_item("Product Bundle Item", {"is_stock_item": 0}) - simple_item = make_item("Simple Item", {"is_stock_item": 1}) - sb_item = make_item( - "Serial Batch Item", - { - "is_stock_item": 1, - "has_serial_no": 1, - "has_batch_no": 1, - "create_new_batch": 1, - "batch_number_series": "BAT-TSBIFRM-.#####", - "serial_no_series": "SN-TSBIFRM-.#####", - }, - ) - make_product_bundle(pb_item.name, [simple_item.name, sb_item.name]) + def test_sales_team_allocated_percentage_tolerates_floating_point_drift(self): + # 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point + so = make_sales_order(do_not_save=True) + for sales_person, percentage in ( + ("_Test Sales Person", 10.0), + ("_Test Sales Person 1", 58.02), + ("_Test Sales Person 2", 31.98), + ): + so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage}) + so.save() - warehouse = "_Test Warehouse - _TC" - - make_stock_entry( - item_code=simple_item.name, - target=warehouse, - qty=10, - ) - - # two different stock entries on purpose to get two batches - make_stock_entry( - item_code=sb_item.name, - target=warehouse, - qty=5, - ) - make_stock_entry( - item_code=sb_item.name, - target=warehouse, - qty=5, - ) - - so = make_sales_order(item_code=pb_item.name, do_not_submit=1) - so.reserve_stock = 1 - for item in so.packed_items: - item.reserve_stock = 1 - so.submit() - - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_batch_nos_details, - get_sre_reserved_qty_for_voucher_detail_no, - get_sre_reserved_serial_nos_details, - ) - - for item in so.packed_items: - self.assertEqual( - get_sre_reserved_qty_for_voucher_detail_no(item.item_code, "Sales Order", so.name, item.name), - item.qty, - ) - - sre_serial_nos = list(get_sre_reserved_serial_nos_details(sb_item.name, warehouse).keys()) - sre_batch_nos = list(get_sre_reserved_batch_nos_details(sb_item.name, warehouse).keys()) - - dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) - dn.save() - - self.assertTrue(dn.packed_items[1].serial_and_batch_bundle) - - from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos - - serial_nos_in_bundle = get_serial_nos(dn.packed_items[1].serial_and_batch_bundle) - batches_in_bundle = list(get_batches_from_bundle(dn.packed_items[1].serial_and_batch_bundle).keys()) - - self.assertEqual(sre_serial_nos, serial_nos_in_bundle) - self.assertEqual(sre_batch_nos, batches_in_bundle) - - dn.items[0].qty = 5 - dn.save() - sabb_doc = frappe.get_doc("Serial and Batch Bundle", dn.packed_items[1].serial_and_batch_bundle) - sabb_doc.entries = sabb_doc.entries[:5] - sabb_doc.company = dn.company - sabb_doc.save() - dn.submit() - - serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) - batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) - - dn1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) - dn1.save() - - self.assertTrue(dn1.packed_items[1].serial_and_batch_bundle) - - from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos - - serial_nos_in_bundle = set(get_serial_nos(dn1.packed_items[1].serial_and_batch_bundle)) - batches_in_bundle = set(get_batches_from_bundle(dn1.packed_items[1].serial_and_batch_bundle).keys()) - - self.assertEqual(serial_nos, serial_nos_in_bundle) - self.assertEqual(batch_nos, batches_in_bundle) - - dn.cancel() - - # test the same thing with sales invoice as well - - si = make_sales_invoice(so.name) - si.update_stock = 1 - si.save() - - self.assertTrue(si.packed_items[1].serial_and_batch_bundle) - - from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos - - serial_nos_in_bundle = get_serial_nos(si.packed_items[1].serial_and_batch_bundle) - batches_in_bundle = list(get_batches_from_bundle(si.packed_items[1].serial_and_batch_bundle).keys()) - - self.assertEqual(sre_serial_nos, serial_nos_in_bundle) - self.assertEqual(sre_batch_nos, batches_in_bundle) - - si.items[0].qty = 5 - si.save() - sabb_doc = frappe.get_doc("Serial and Batch Bundle", si.packed_items[1].serial_and_batch_bundle) - sabb_doc.entries = sabb_doc.entries[:5] - sabb_doc.company = si.company - sabb_doc.save() - si.submit() - - serial_nos = set(sre_serial_nos) - set(get_serial_nos(sabb_doc.name)) - batch_nos = set(sre_batch_nos) - set(get_batches_from_bundle(sabb_doc.name).keys()) - - si1 = make_delivery_note(so.name, kwargs={"for_reserved_stock": True}) - si1.save() - - self.assertTrue(si1.packed_items[1].serial_and_batch_bundle) - - from erpnext.stock.serial_batch_bundle import get_batches_from_bundle, get_serial_nos - - serial_nos_in_bundle = set(get_serial_nos(si1.packed_items[1].serial_and_batch_bundle)) - batches_in_bundle = set(get_batches_from_bundle(si1.packed_items[1].serial_and_batch_bundle).keys()) - - self.assertEqual(serial_nos, serial_nos_in_bundle) - self.assertEqual(batch_nos, batches_in_bundle) - - def test_sales_team_contribution_follows_grant_commission(self): - """Sales-person allocation tracks the grant-commission-eligible amount, not the gross total. - - The Item "Grant Commission" flag includes an item in both Sales Partner and Sales Person - commission, so each sales person's allocated_amount is a share of - amount_eligible_for_commission rather than net_total. - """ - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 0) - try: - so = make_sales_order( - do_not_save=True, - item_list=[ - {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 100}, - { - "item_code": "_Test FG Item", - "warehouse": "_Test Warehouse - _TC", - "qty": 10, - "rate": 100, - }, - ], - ) - so.append( - "sales_team", - {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60, "commission_rate": 10}, - ) - so.append( - "sales_team", - {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40, "commission_rate": 0}, - ) - so.save() - - self.assertEqual(so.net_total, 2000) - self.assertEqual(so.amount_eligible_for_commission, 1000) # only the grant_commission item - - first, second = so.sales_team - # allocation follows the eligible amount (1000), not net_total (2000) - self.assertEqual(first.allocated_amount, 600) - self.assertEqual(first.incentives, 60) # 600 * 10% - self.assertEqual(second.allocated_amount, 400) - self.assertEqual(second.incentives, 0) - finally: - # grant_commission defaults to 1 for both items; restore - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - frappe.db.set_value("Item", "_Test FG Item", "grant_commission", 1) - - def test_sales_team_allocated_percentage_must_total_100(self): - with self.subTest("partial allocation is rejected"): - so = make_sales_order(do_not_save=True) - so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) - self.assertRaises(frappe.ValidationError, so.save) - - with self.subTest("allocation totalling 100 is accepted"): - so = make_sales_order(do_not_save=True) - so.append("sales_team", {"sales_person": "_Test Sales Person 1", "allocated_percentage": 60}) - so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 40}) - so.save() - self.assertEqual(sum(d.allocated_percentage for d in so.sales_team), 100) - - with self.subTest("floating-point drift in the total is tolerated"): - # 10.0 + 58.02 + 31.98 accumulates to 100.00000000000001 in binary floating point - so = make_sales_order(do_not_save=True) - for sales_person, percentage in ( - ("_Test Sales Person", 10.0), - ("_Test Sales Person 1", 58.02), - ("_Test Sales Person 2", 31.98), - ): - so.append("sales_team", {"sales_person": sales_person, "allocated_percentage": percentage}) - so.save() - - def test_sales_team_disabled_sales_person_rejected(self): - frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 0) - try: - so = make_sales_order(do_not_save=True) - so.append("sales_team", {"sales_person": "_Test Sales Person 2", "allocated_percentage": 100}) - self.assertRaises(frappe.ValidationError, so.save) - finally: - frappe.db.set_value("Sales Person", "_Test Sales Person 2", "enabled", 1) - - def test_sales_partner_commission(self): - """Sales Partner commission: total_commission = amount_eligible_for_commission * rate / 100.""" - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - try: - so = make_sales_order(qty=10, rate=100, do_not_save=True) - so.sales_partner = "_Test Sales Partner India - 1" - so.commission_rate = 7 - so.save() - - self.assertEqual(so.amount_eligible_for_commission, 1000) - self.assertEqual(so.total_commission, 70) # 1000 * 7% - - with self.subTest("commission rate above 100 is rejected"): - so.commission_rate = 101 - self.assertRaises(frappe.ValidationError, so.save) - finally: - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - - def test_commission_fields_not_copied_on_duplicate(self): - """Commission rate/amount fields are no_copy; only the sales partner carries to a copy.""" - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - try: - so = make_sales_order(qty=10, rate=100, do_not_save=True) - so.sales_partner = "_Test Sales Partner India - 1" - so.commission_rate = 7 - so.save() - self.assertEqual(so.total_commission, 70) - - # ignore_no_copy=False mirrors UI "Duplicate"/amend, which honour no_copy - duplicate = frappe.copy_doc(so, ignore_no_copy=False) - self.assertEqual(duplicate.sales_partner, "_Test Sales Partner India - 1") - self.assertFalse(duplicate.commission_rate) - self.assertFalse(duplicate.total_commission) - self.assertFalse(duplicate.amount_eligible_for_commission) - finally: - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - - def test_commission_rate_carried_through_mapper(self): - """commission_rate is no_copy, but Make Delivery Note / Sales Invoice still carries it.""" - from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice - - original = frappe.db.get_value("Item", "_Test Item", "grant_commission") - frappe.db.set_value("Item", "_Test Item", "grant_commission", 1) - try: - so = make_sales_order(qty=10, rate=100, do_not_save=True) - so.sales_partner = "_Test Sales Partner India - 1" - so.commission_rate = 7 - so.submit() - - # carried to the mapped (unsaved) documents even though the field is no_copy - self.assertEqual(make_delivery_note(so.name).commission_rate, 7) - self.assertEqual(make_sales_invoice(so.name).commission_rate, 7) - finally: - frappe.db.set_value("Item", "_Test Item", "grant_commission", original) - ->>>>>>> 4afba94d1c (test: sales team allocation totalling 100 in floating point) def compare_payment_schedules(doc, doc1, doc2): for index, schedule in enumerate(doc1.get("payment_schedule")): From d4ed2074d5c29ebfca28b4b458db88b6cb6f2f2a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 13:01:01 +0530 Subject: [PATCH 29/60] fix: use stock settings for warehouse defaults --- .../doctype/purchase_order/test_purchase_order.py | 10 +++++++--- erpnext/controllers/accounts_controller.py | 2 +- .../selling/doctype/sales_order/test_sales_order.py | 11 ++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index dd899a14588..81c992214d2 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -247,9 +247,11 @@ class TestPurchaseOrder(FrappeTestCase): po.submit() first_item_of_po = po.get("items")[0] - company_default = frappe.db.get_value("Company", po.company, "default_warehouse") - frappe.db.set_value("Company", po.company, "default_warehouse", None) - self.addCleanup(frappe.db.set_value, "Company", po.company, "default_warehouse", company_default) + stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse") + frappe.db.set_single_value("Stock Settings", "default_warehouse", None) + self.addCleanup( + frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default + ) def get_trans_items(item_code): return json.dumps( @@ -463,11 +465,13 @@ class TestPurchaseOrder(FrappeTestCase): "item_code": item, "rate": 100, "qty": 1, + "warehouse": po.items[0].warehouse, }, # added item whose tax account head already exists in PO { "item_code": new_item_with_tax.name, "rate": 100, "qty": 1, + "warehouse": po.items[0].warehouse, }, # added item whose tax account head is missing in PO ] ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6ee13e7cb1f..a3b3e17c29d 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3753,7 +3753,7 @@ def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: s if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item): frappe.throw( _( - "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in the Company." + "Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in Stock Settings." ).format(frappe.bold(item.item_code)) ) return None diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index ccead71f71e..d021da8c3b5 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -629,10 +629,11 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so = make_sales_order(item_code="_Test Item", qty=4) existing_item = so.get("items")[0] - # a company gets a default warehouse when its warehouses are created - company_default = frappe.db.get_value("Company", so.company, "default_warehouse") - frappe.db.set_value("Company", so.company, "default_warehouse", None) - self.addCleanup(frappe.db.set_value, "Company", so.company, "default_warehouse", company_default) + stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse") + frappe.db.set_single_value("Stock Settings", "default_warehouse", None) + self.addCleanup( + frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default + ) def get_trans_items(warehouse=None): new_row = {"item_code": item_code, "rate": 200, "qty": 7} @@ -651,7 +652,7 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): ] ) - # no default in the Item Master, Item Group, Brand or Company + # no default in the Item Master, Item Group, Brand or Stock Settings self.assertRaisesRegex( frappe.ValidationError, "Cannot find a default warehouse", From c1d198d20521fb76236effa7c652661958da46bb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 15:45:07 +0530 Subject: [PATCH 30/60] fix: sync open reference forms after Quality Inspection updates them update_qc_reference() writes the QI link and bumps the reference document's modified timestamp via raw db writes, which emit no realtime event. A reference form (Purchase Receipt, Delivery Note, Stock Entry, Job Card) still open in the browser keeps the old timestamp and fails the timestamp conflict check on the next save/submit, forcing a manual refresh after every QI submit/cancel/delete. Calling notify_update() on the reference publishes the standard doc_update event, so an open, unedited form silently reloads and syncs its timestamp. get_lazy_doc skips child table loading since notify_update only needs the parent row. (cherry picked from commit 647452c95befc0cb6b749478372b95899e059a1d) --- erpnext/stock/doctype/quality_inspection/quality_inspection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index a7733b0bf8b..1fbf323f0dd 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -253,6 +253,9 @@ class QualityInspection(Document): self.modified, ) + if self.reference_type and self.reference_name: + frappe.get_lazy_doc(self.reference_type, self.reference_name).notify_update() + def inspect_and_set_status(self): for reading in self.readings: if not reading.manual_inspection: # dont auto set status if manual From 2270b2240027b88fc70c3243a708bbfbb4075853 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 15:53:37 +0530 Subject: [PATCH 31/60] test: doc_update published for reference on Quality Inspection submit (cherry picked from commit e8a6884d5ec7b1ed6b4ca21b21fb5a392e7b157b) # Conflicts: # erpnext/stock/doctype/quality_inspection/test_quality_inspection.py --- .../test_quality_inspection.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index e49a0b3b678..96b8760d349 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -1,6 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt +<<<<<<< HEAD +======= +from contextlib import contextmanager +from unittest.mock import patch + +>>>>>>> e8a6884d5e (test: doc_update published for reference on Quality Inspection submit) import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate @@ -58,6 +64,27 @@ class TestQualityInspection(FrappeTestCase): qa.delete() dn.delete() + def test_doc_update_published_for_reference_on_submit(self): + """Submitting a QI publishes doc_update so open reference forms resync their timestamp.""" + dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) + qa = create_quality_inspection( + reference_type="Delivery Note", reference_name=dn.name, do_not_submit=True + ) + + with patch.object(frappe, "publish_realtime") as publish_realtime: + qa.submit() + + reference_updates = [ + call + for call in publish_realtime.call_args_list + if call.args and call.args[0] == "doc_update" and call.kwargs.get("docname") == dn.name + ] + self.assertEqual(len(reference_updates), 1) + + message = reference_updates[0].args[1] + self.assertEqual(message["doctype"], "Delivery Note") + self.assertEqual(message["modified"], frappe.db.get_value("Delivery Note", dn.name, "modified")) + def test_value_based_qi_readings(self): # Test QI based on acceptance values (Non formula) dn = create_delivery_note(item_code="_Test Item with QA", do_not_submit=True) From 94d1dfdcb4d44813e9a8be356250912ea7799f40 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 9 Aug 2026 16:18:49 +0530 Subject: [PATCH 32/60] chore: resolve conflict --- .../doctype/quality_inspection/test_quality_inspection.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py index 96b8760d349..17b9a82c492 100644 --- a/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/test_quality_inspection.py @@ -1,12 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt -<<<<<<< HEAD -======= -from contextlib import contextmanager from unittest.mock import patch ->>>>>>> e8a6884d5e (test: doc_update published for reference on Quality Inspection submit) import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate From 91afc04d9f6b4f43f1fba7d488be3aa74ee21506 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 9 Aug 2026 20:22:31 +0530 Subject: [PATCH 33/60] fix: reflect in-invoice receivable settlements in Sales Register ledger view --- .../report/sales_register/sales_register.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index e55f217682d..bbffaa7d015 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -151,7 +151,13 @@ def _execute(filters, additional_table_columns=None): ) if inv.doctype == "Sales Invoice": - row.update({"debit": inv.base_grand_total, "credit": 0.0}) + # credit only settlements the invoice itself posts to the receivable (mirrors its GL) + row.update( + { + "debit": inv.base_grand_total, + "credit": get_in_invoice_receivable_credit(inv), + } + ) else: row.update({"debit": 0.0, "credit": inv.base_grand_total}) data.append(row) @@ -167,6 +173,14 @@ def _execute(filters, additional_table_columns=None): return columns, res, None, None, None, include_payments +def get_in_invoice_receivable_credit(inv): + # amount the invoice settles against its own receivable, matching the invoice's GL entries + credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not + if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices + credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount) + return credit + + def get_columns(invoice_list, additional_table_columns, include_payments=False): """return columns based on filters""" columns = [ @@ -433,6 +447,11 @@ def get_invoices(filters, additional_query_columns): si.base_net_total, si.base_grand_total, si.base_rounded_total, + si.is_pos, + si.base_paid_amount, + si.base_change_amount, + si.base_write_off_amount, + si.loyalty_amount, si.outstanding_amount, si.is_internal_customer, si.represents_company, From 7edb95628c943254ffd63a22cc5b2716141c61e5 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 9 Aug 2026 20:22:31 +0530 Subject: [PATCH 34/60] test: cover POS-paid invoice in Sales Register ledger view --- .../sales_register/test_sales_register.py | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/sales_register/test_sales_register.py b/erpnext/accounts/report/sales_register/test_sales_register.py index 9e72f81f6e5..3b8b14a763d 100644 --- a/erpnext/accounts/report/sales_register/test_sales_register.py +++ b/erpnext/accounts/report/sales_register/test_sales_register.py @@ -1,7 +1,8 @@ import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import getdate, today +from frappe.utils import flt, getdate, today +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.sales_register.sales_register import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin @@ -54,6 +55,46 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): si = si.submit() return si + def test_ledger_view_nets_pos_paid_invoice(self): + # A POS payment settles the receivable inside the invoice, so the ledger view must credit it + # and net to zero instead of showing a phantom outstanding. + make_pos_profile() + si = create_sales_invoice( + item=self.item, + company=self.company, + customer=self.customer, + debit_to=self.debit_to, + posting_date=today(), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + rate=100, + price_list_rate=100, + do_not_save=1, + ) + si.is_pos = 1 + si.append("payments", {"mode_of_payment": "Cash", "amount": 100}) + si = si.save().submit() + self.assertEqual(flt(si.outstanding_amount), 0.0) + + filters = frappe._dict( + { + "from_date": today(), + "to_date": today(), + "company": self.company, + "include_payments": True, + "customer": self.customer, + } + ) + rows = execute(filters)[1] + inv_row = next(x for x in rows if x.get("voucher_no") == si.name) + + self.assertEqual(flt(inv_row.get("debit")), 100.0) + self.assertEqual(flt(inv_row.get("credit")), 100.0) + + # running balance is unchanged by a fully-paid POS invoice + idx = rows.index(inv_row) + self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance"))) + def test_basic_report_output(self): si = self.create_sales_invoice(rate=98) From 2ca71f16c4ab77091dc46f0a786e70c9caa1d363 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:30:11 +0000 Subject: [PATCH 35/60] fix: escape `customer_details` on lead creation from appointment (backport #57947) (#57948) Co-authored-by: Diptanil Saha --- erpnext/crm/doctype/appointment/appointment.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/appointment/appointment.py b/erpnext/crm/doctype/appointment/appointment.py index da91a73f105..8beed20befa 100644 --- a/erpnext/crm/doctype/appointment/appointment.py +++ b/erpnext/crm/doctype/appointment/appointment.py @@ -13,6 +13,7 @@ from frappe.model.document import Document from frappe.share import add_docshare from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime from frappe.utils.data import sha256_hash +from frappe.utils.html_utils import escape_html from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday @@ -269,7 +270,11 @@ class Appointment(Document): if self.customer_details: lead.append( "notes", - {"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()}, + { + "note": escape_html(self.customer_details), + "added_by": frappe.session.user, + "added_on": now(), + }, ) self.party = lead.insert(ignore_permissions=True).name From f9a09f0ac4dd273fee403b8e0483b735afa81b52 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:48:58 +0000 Subject: [PATCH 36/60] fix: skip incoming rate calc when serial no qty is zero (backport #57427) (#57956) fix: skip incoming rate calc when serial no qty is zero (#57427) (cherry picked from commit a25decfa50a355307ad187201352f7c465d79eba) Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com> --- erpnext/stock/serial_batch_bundle.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index dc659cb79ab..967fc8bea3e 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -774,6 +774,9 @@ class SerialNoValuation(DeprecatedSerialNoValuation): return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse) def get_incoming_rate(self): + if not self.sle.actual_qty and self.sle.voucher_type == "Stock Reconciliation": + return 0.0 + return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) def get_incoming_rate_of_serial_no(self, serial_no): From d510978a44773d3ccac50971ce6be910811b8800 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Mon, 10 Aug 2026 14:03:41 +0530 Subject: [PATCH 37/60] fix: re-check future sle before queuing repost on submit (#57664) (#57960) * test: cover both repost branches and the no-repost case * fix: queue repost for entries backdated by a concurrent submit --------- (cherry picked from commit 399ff463ccf417a34134edd2bac40aa7263a6cfb) Co-authored-by: nareshkannasln --- erpnext/controllers/stock_controller.py | 5 + .../tests/test_stock_controller.py | 184 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 4 +- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 erpnext/controllers/tests/test_stock_controller.py diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 269f85ffcbb..f5e344a4045 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1778,6 +1778,11 @@ def is_reposting_pending(): ) +def invalidate_future_sle_cache(voucher_type, voucher_no): + if hasattr(frappe.local, "future_sle"): + frappe.local.future_sle.pop((voucher_type, voucher_no), None) + + def future_sle_exists(args, sl_entries=None): from erpnext.stock.utils import get_combine_datetime diff --git a/erpnext/controllers/tests/test_stock_controller.py b/erpnext/controllers/tests/test_stock_controller.py new file mode 100644 index 00000000000..39b8a884b9d --- /dev/null +++ b/erpnext/controllers/tests/test_stock_controller.py @@ -0,0 +1,184 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, today + + +class TestStockControllerConversions(FrappeTestCase): + def tearDown(self): + # FrappeTestCase only rolls back once per class, so undo this test's writes here: a + # submitted Repost Item Valuation left behind cannot be deleted by the cleanups below. + frappe.db.rollback() + if hasattr(frappe.local, "future_sle"): + frappe.local.future_sle.clear() + + @staticmethod + def _cancel_and_delete(doctype, name): + if not frappe.db.exists(doctype, name): + return + doc = frappe.get_doc(doctype, name) + if doc.docstatus == 1: + doc.cancel() + frappe.delete_doc(doctype, name, force=1) + + def test_future_sle_exists_detects_later_entries(self): + # A later SLE for the same item+warehouse must be reported as a future entry, which + # exercises the GROUP BY query in future_sle_exists on both engines. + from erpnext.controllers.stock_controller import future_sle_exists + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + item = make_item("_Test Future SLE Item", {"is_stock_item": 1}).name + se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=10, basic_rate=100) + self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name) + + # Pretend a different voucher posts a day earlier for the same item/warehouse: the existing + # (later) SLE must be reported as a future entry. + args = frappe._dict( + voucher_type="Stock Entry", + voucher_no="_TEST-NONEXISTENT-SE", + posting_date=add_days(today(), -1), + posting_time="00:00:00", + ) + sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")] + + self.assertTrue(future_sle_exists(args, sl_entries)) + + def _make_opening_entry(self, item, warehouse): + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + opening = make_stock_entry( + item_code=item, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(today(), -5), + posting_time="01:00:00", + ) + self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name) + + return opening + + def _later_sle(self, item, warehouse, opening): + sle = frappe.get_doc( + { + "doctype": "Stock Ledger Entry", + "item_code": item, + "warehouse": warehouse, + "posting_date": today(), + "posting_time": "12:00:00", + "voucher_type": "Stock Entry", + "voucher_no": opening.name, + "actual_qty": 7, + "incoming_rate": 100, + "qty_after_transaction": 107, + "valuation_rate": 100, + "stock_value": 10700, + "company": opening.company, + "stock_uom": "Nos", + } + ) + sle.flags.ignore_permissions = True + sle.flags.ignore_links = True + + return sle + + def _submit_entry(self, item, warehouse, inject=None): + from erpnext.stock import stock_ledger + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + original_make_entry = stock_ledger.make_entry + injected = [] + + def make_entry_with_injection(*args, **kwargs): + if inject is not None and not injected: + injected.append(True) + inject.submit() + return original_make_entry(*args, **kwargs) + + stock_ledger.make_entry = make_entry_with_injection + try: + entry = make_stock_entry( + item_code=item, + target=warehouse, + qty=5, + basic_rate=500, + posting_date=today(), + posting_time="06:00:00", + ) + finally: + stock_ledger.make_entry = original_make_entry + + self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name) + if inject is not None: + self.assertTrue(injected, "the later SL Entry was not written during the submit") + + return entry + + def _reposts_queued_for(self, item, warehouse, voucher_no): + names = set( + frappe.get_all( + "Repost Item Valuation", + filters={"docstatus": 1, "item_code": item, "warehouse": warehouse}, + pluck="name", + ) + ) | set( + frappe.get_all( + "Repost Item Valuation", + filters={"docstatus": 1, "voucher_no": voucher_no}, + pluck="name", + ) + ) + for name in names: + self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1) + + return names + + def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + + opening = self._make_opening_entry(item, warehouse) + backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening)) + + self.assertTrue( + self._reposts_queued_for(item, warehouse, backdated.name), + "No Repost Item Valuation was queued for an entry that a later SL Entry made backdated", + ) + + def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + + with change_settings("Stock Reposting Settings", item_based_reposting=0): + opening = self._make_opening_entry(item, warehouse) + backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening)) + + self.assertTrue( + frappe.get_all( + "Repost Item Valuation", + filters={"docstatus": 1, "voucher_no": backdated.name}, + pluck="name", + ), + "No voucher based Repost Item Valuation was queued", + ) + + def test_no_repost_queued_when_nothing_was_written_after_the_entry(self): + from erpnext.stock.doctype.item.test_item import make_item + + item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + + self._make_opening_entry(item, warehouse) + entry = self._submit_entry(item, warehouse) + + self.assertFalse( + self._reposts_queued_for(item, warehouse, entry.name), + "A Repost Item Valuation was queued for an entry with nothing posted after it", + ) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 52ca6418fc1..7a9bc900307 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -65,7 +65,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc such cases certain validations need to be ignored (like negative stock) """ - from erpnext.controllers.stock_controller import future_sle_exists + from erpnext.controllers.stock_controller import future_sle_exists, invalidate_future_sle_cache if sl_entries: cancelled = sl_entries[0].get("is_cancelled") @@ -113,6 +113,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc _("Item {0} ignored since it is not a stock item").format(args.get("item_code")) ) + invalidate_future_sle_cache(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no")) + def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_voucher=False): if args.get("actual_qty") or args.get("voucher_type") == "Stock Reconciliation": From 713af31edab09416a7142de4dc451ef05141e75b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:27:03 +0000 Subject: [PATCH 38/60] fix: preserve custom title on new JV (backport #57987) (#57988) Co-authored-by: rehanrehman389 Co-authored-by: Diptanil Saha --- erpnext/accounts/doctype/journal_entry/journal_entry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 5337baed358..8acd5cf8587 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -154,7 +154,8 @@ class JournalEntry(AccountsController): if self.docstatus == 0: self.apply_tax_withholding() - if self.is_new() or not self.title: + + if not self.title or (self.is_new() and self.amended_from): self.title = self.get_title() def validate_advance_accounts(self): From a0d156120b2ebb9d22ab1556104b38b8faa85126 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:04:42 +0530 Subject: [PATCH 39/60] fix: field validation and perm checks on `get_stock_reservation_entries_for_voucher` (backport #57968) (#57986) Co-authored-by: Diptanil Saha --- .../stock_reservation_entry.py | 27 ++++++++++++++----- .../test_stock_reservation_entry.py | 16 +++++------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index ca31f33bf76..32b42d85cdc 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -882,7 +882,7 @@ def get_ssb_bundle_for_voucher(sre: dict) -> object: def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str | None = None) -> bool: """Returns True if there is any Stock Reservation Entry for the given voucher.""" - if get_stock_reservation_entries_for_voucher( + if _get_stock_reservation_entries_for_voucher( voucher_type, voucher_no, voucher_detail_no, fields=["name"], ignore_status=True ): return True @@ -1113,7 +1113,7 @@ def cancel_stock_reservation_entries( sre_list = {} if voucher_type and voucher_no: - sre_list = get_stock_reservation_entries_for_voucher( + sre_list = _get_stock_reservation_entries_for_voucher( voucher_type, voucher_no, voucher_detail_no, fields=["name"] ) elif from_voucher_type and from_voucher_no: @@ -1156,6 +1156,24 @@ def get_stock_reservation_entries_for_voucher( ) -> list[dict]: """Returns list of Stock Reservation Entries against a Voucher.""" + return _get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields, ignore_status, ignore_permissions=False + ) + + +def _get_stock_reservation_entries_for_voucher( + voucher_type: str, + voucher_no: str, + voucher_detail_no: str | None = None, + fields: list[str] | None = None, + ignore_status: bool = False, + ignore_permissions: bool = True, +) -> list[dict]: + """Returns list of Stock Reservation Entries against a Voucher.""" + + if not ignore_permissions: + frappe.has_permission(voucher_type, doc=voucher_no, throw=True) + if not fields or not isinstance(fields, list): fields = [ "name", @@ -1169,14 +1187,11 @@ def get_stock_reservation_entries_for_voucher( sre = frappe.qb.DocType("Stock Reservation Entry") query = ( - frappe.qb.from_(sre) + frappe.get_query(sre, fields=fields) .where((sre.docstatus == 1) & (sre.voucher_type == voucher_type) & (sre.voucher_no == voucher_no)) .orderby(sre.creation) ) - for field in fields: - query = query.select(sre[field]) - if voucher_detail_no: query = query.where(sre.voucher_detail_no == voucher_detail_no) diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py index 942c7f482ae..deba0d967c8 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -13,9 +13,9 @@ from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + _get_stock_reservation_entries_for_voucher, cancel_stock_reservation_entries, get_sre_reserved_qty_details_for_voucher, - get_stock_reservation_entries_for_voucher, has_reserved_stock, ) from erpnext.stock.utils import get_stock_balance @@ -278,7 +278,7 @@ class TestStockReservationEntry(FrappeTestCase): self.assertTrue(has_reserved_stock("Sales Order", so.name)) for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, fields=["reserved_qty", "status"] )[0] self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) @@ -335,7 +335,7 @@ class TestStockReservationEntry(FrappeTestCase): dn1.submit() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, fields=["delivered_qty", "status"] )[0] self.assertGreater(sre_details.delivered_qty, 0) @@ -352,7 +352,7 @@ class TestStockReservationEntry(FrappeTestCase): dn2.submit() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, @@ -396,7 +396,7 @@ class TestStockReservationEntry(FrappeTestCase): so.load_from_db() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, fields=["status", "reserved_qty"] )[0] @@ -411,7 +411,7 @@ class TestStockReservationEntry(FrappeTestCase): dn.submit() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, fields=["status", "delivered_qty", "reserved_qty"] )[0] @@ -459,7 +459,7 @@ class TestStockReservationEntry(FrappeTestCase): so.load_from_db() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, @@ -520,7 +520,7 @@ class TestStockReservationEntry(FrappeTestCase): so.load_from_db() for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( + sre_details = _get_stock_reservation_entries_for_voucher( "Sales Order", so.name, item.name, fields=["reserved_qty"] )[0] From 27af9baaa58479ca83664261218f68f672c19a09 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:51:55 +0000 Subject: [PATCH 40/60] refactor(queries): using `frappe.get_query` in `get_filtered_child_rows` (backport #57991) (#57992) Co-authored-by: Diptanil Saha --- erpnext/controllers/queries.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 88a40eb72b1..3b0602fa209 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -967,9 +967,8 @@ def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len, def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) -> list: table = frappe.qb.DocType(doctype) query = ( - frappe.qb.from_(table) + frappe.get_query(table, filters=filters) .select( - table.name, Concat("#", table.idx, ", ", table.item_code), ) .orderby(table.idx) @@ -977,10 +976,6 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) .limit(page_len) ) - if filters: - for field, value in filters.items(): - query = query.where(table[field] == value) - if txt: txt += "%" query = query.where( From 5e753ec6e4bdb9b7e6dcd9a3aeae05bf478ce835 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 10 Aug 2026 16:02:39 +0530 Subject: [PATCH 41/60] fix: convert hours to minutes in workstation complete_job `time_diff_in_hours` returns hours, so `time_in_mins` needs `* 60`, not `/ 60`. Matches `Job Card.validate_time_log_row`. No behaviour change: the `doc.save()` on the next line runs Job Card's `validate`, which recomputes `time_in_mins` correctly before the row is written. This only stops the expression from reading as a bug. (cherry picked from commit 422a9161ddb34a542a9fb226e3250f7ff6f4d8cb) --- erpnext/manufacturing/doctype/workstation/workstation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index d996d8a98ea..7770f530045 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -189,7 +189,7 @@ class Workstation(Document): for row in doc.time_logs: if not row.to_time: row.to_time = to_time - row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60 + row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) * 60 row.completed_qty = qty doc.save() From 496883f4e18f0d0952ff89919d0175f6ac6eea74 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 11:56:13 +0530 Subject: [PATCH 42/60] refactor: drop redundant time_in_mins assignment in complete_job (cherry picked from commit 3cffeb68e3e719eddc3c3ceb34166f8311782451) --- erpnext/manufacturing/doctype/workstation/workstation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 7770f530045..ac7b5f043e2 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -189,7 +189,6 @@ class Workstation(Document): for row in doc.time_logs: if not row.to_time: row.to_time = to_time - row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) * 60 row.completed_qty = qty doc.save() From 0310db22ecb7880da67acc24b7827a02196e3d99 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:58:33 +0530 Subject: [PATCH 43/60] fix(manufacturing): keep item code searchable when a barcode matches the same text (cherry picked from commit bf5d506637a0f74333310f1b8d0473b11254426e) # Conflicts: # erpnext/manufacturing/doctype/bom/mapper.py --- erpnext/manufacturing/doctype/bom/mapper.py | 206 ++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 erpnext/manufacturing/doctype/bom/mapper.py diff --git a/erpnext/manufacturing/doctype/bom/mapper.py b/erpnext/manufacturing/doctype/bom/mapper.py new file mode 100644 index 00000000000..f237172fb56 --- /dev/null +++ b/erpnext/manufacturing/doctype/bom/mapper.py @@ -0,0 +1,206 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +"""Document-mapping and query helpers for BOM (extracted from bom.py).""" + +from functools import partial + +import frappe +from frappe import _ +from frappe.core.doctype.version.version import get_diff +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.query_builder import Field +from frappe.query_builder.functions import IfNull +from frappe.utils import today + +from erpnext.stock.doctype.item.item import get_item_details + +_BOM_DIFF_IDENTIFIERS = { + "operations": "operation", + "items": "item_code", + "secondary_items": "item_code", + "exploded_items": "item_code", +} + +_VARIANT_BOM_MAPPING = { + "BOM": {"doctype": "BOM", "validation": {"docstatus": ["=", 1]}}, + "BOM Item": { + "doctype": "BOM Item", + # stop get_mapped_doc copying parent bom_no to children + "field_no_map": ["bom_no"], + "condition": lambda doc: doc.has_variants == 0, + }, +} + + +@frappe.whitelist() +def get_children(parent: str | None = None, is_root: bool = False, **filters): + frappe.has_permission("BOM", "read", throw=True) + + if not parent or parent == "BOM": + frappe.msgprint(_("Please select a BOM")) + return + + frappe.form_dict.parent = parent + bom_doc = frappe.get_cached_doc("BOM", parent) + frappe.has_permission("BOM", doc=bom_doc, throw=True) + + bom_items = _bom_child_items(parent) + _enrich_bom_items(bom_items, bom_doc) + return bom_items + + +def _bom_child_items(parent): + return frappe.get_all( + "BOM Item", + fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"], + filters=[["parent", "=", parent]], + order_by="idx", + ) + + +def _enrich_bom_items(bom_items, bom_doc): + item_names = tuple(d.get("item_code") for d in bom_items) + items = frappe.get_list( + "Item", + fields=["image", "description", "name", "stock_uom", "item_name", "is_sub_contracted_item"], + filters=[["name", "in", item_names]], + ) + for bom_item in bom_items: + bom_item.update(next(item for item in items if item.get("name") == bom_item.get("item_code"))) + bom_item.parent_bom_qty = bom_doc.quantity + bom_item.expandable = 0 if bom_item.value in ("", None) else 1 + bom_item.image = frappe.db.escape(bom_item.image) + + +@frappe.whitelist() +def get_bom_diff(bom1: str, bom2: str): + frappe.has_permission("BOM", "read", throw=True) + if bom1 == bom2: + frappe.throw( + _("BOM 1 {0} and BOM 2 {1} should not be the same").format(frappe.bold(bom1), frappe.bold(bom2)) + ) + + doc1 = frappe.get_doc("BOM", bom1) + doc2 = frappe.get_doc("BOM", bom2) + + out = get_diff(doc1, doc2) + out.row_changed, out.added, out.removed = [], [], [] + for df in doc1.meta.fields: + _diff_table_field(df, doc1, doc2, out) + return out + + +def _diff_table_field(df, doc1, doc2, out): + from frappe.model import table_fields + + if df.fieldtype not in table_fields: + return + + identifier = _BOM_DIFF_IDENTIFIERS[df.fieldname] + old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname) + old_map = {d.get(identifier): d for d in old_value} + new_map = {d.get(identifier): d for d in new_value} + + _collect_row_changes(df, identifier, old_map, new_value, out) + for d in old_value: + if d.get(identifier) not in new_map: + out.removed.append([df.fieldname, d.as_dict()]) + + +def _collect_row_changes(df, identifier, old_map, new_value, out): + for i, d in enumerate(new_value): + if d.get(identifier) not in old_map: + out.added.append([df.fieldname, d.as_dict()]) + continue + + diff = get_diff(old_map[d.get(identifier)], d, for_child=True) + if diff and diff.changed: + out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed)) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def item_query( + doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None +): + frappe.has_permission("Item", "read", throw=True) + + searchfields = frappe.get_meta("Item", cached=True).get_search_fields() + fields = ["name", "item_name", "item_group", "description"] + fields.extend(f for f in searchfields if f not in ["name", "item_group", "description"]) + + query_filters = _item_query_filters(filters) + or_filters = _item_query_or_filters(txt, searchfields or ["name"], query_filters) + return frappe.get_list( + "Item", + fields=fields, + filters=query_filters, + or_filters=or_filters, + order_by="idx desc, name, item_name", + limit_start=start, + limit_page_length=page_len, + as_list=1, + ) + + +def _item_query_filters(filters): + query_filters = [["disabled", "=", 0], [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()]] + if filters and filters.get("item_code"): + if not frappe.get_cached_value("Item", filters.get("item_code"), "has_variants"): + query_filters.append(["has_variants", "=", 0]) + + for fieldname, value in (filters or {}).items(): + query_filters.append([fieldname, "=", value]) + return query_filters + + +def _item_query_or_filters(txt, searchfields, query_filters): + if not txt: + return [] + + or_filters = [[s_field, "like", f"%{txt}%"] for s_field in searchfields] + barcodes = frappe.get_all( + "Item Barcode", + fields=["parent as item_code"], + filters={"barcode": ("like", f"%{txt}%")}, + distinct=True, + ) + barcode_codes = [d.item_code for d in barcodes] + if barcode_codes: + or_filters.append(["name", "in", barcode_codes]) + return or_filters + + +@frappe.whitelist() +def make_variant_bom( + source_name: str, + bom_no: str, + item: str, + variant_items: str | list, + target_doc: str | dict | Document | None = None, +): + frappe.has_permission("BOM", "write", throw=True) + + postprocess = partial( + _postprocess_variant_bom, item=item, variant_items=variant_items, source_name=source_name + ) + return get_mapped_doc("BOM", source_name, _VARIANT_BOM_MAPPING, target_doc, postprocess) + + +def _postprocess_variant_bom(source, doc, item, variant_items, source_name): + from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item + + item_data = get_item_details(item) + doc.item = item + doc.quantity = 1 + doc.update( + { + "item_name": item_data.item_name, + "description": item_data.description, + "uom": item_data.stock_uom, + "allow_alternative_item": item_data.allow_alternative_item, + } + ) + add_variant_item(variant_items, doc, source_name) From 760b3652250c11d30d54ca1422ed21170224c842 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:58:36 +0530 Subject: [PATCH 44/60] test(manufacturing): cover BOM item search when item code collides with a barcode (cherry picked from commit 23024d1ea91ed7e1b5cd9e658e468d18a42af411) --- erpnext/manufacturing/doctype/bom/test_bom.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index cc942d59c74..60cb68d23cc 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -461,6 +461,29 @@ class TestBOM(FrappeTestCase): self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results") self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + @timeout + def test_bom_item_query_matches_item_code_colliding_with_another_barcode(self): + item = make_item( + "_Test BOM Query 2.5MM", + {"is_stock_item": 1, "item_name": "_Test BOM Query Sheet", "description": "sheet"}, + ) + make_item( + "_Test BOM Query Barcode Holder", + {"is_stock_item": 1}, + barcode=f"90{item.name}90", + ) + + results = item_query( + doctype="Item", + txt=item.name, + searchfield="name", + start=0, + page_len=20, + filters={"is_stock_item": 1}, + ) + + self.assertIn(item.name, [d[0] for d in results]) + @timeout def test_exclude_exploded_items_from_bom(self): bom_no = get_default_bom() From a9743ae5dacd77bf2a6c00a437417f048cca8cbd Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:25:29 +0530 Subject: [PATCH 45/60] chore: resolve backport conflict in bom.py --- erpnext/manufacturing/doctype/bom/bom.py | 6 +- erpnext/manufacturing/doctype/bom/mapper.py | 206 -------------------- 2 files changed, 3 insertions(+), 209 deletions(-) delete mode 100644 erpnext/manufacturing/doctype/bom/mapper.py diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 5976cb4fa94..3a799a508dd 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1577,10 +1577,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): query_filters = {"disabled": 0, "ifnull(end_of_life, '3099-12-31')": (">", today())} - or_cond_filters = {} + or_cond_filters = [] if txt: for s_field in searchfields: - or_cond_filters[s_field] = ("like", f"%{txt}%") + or_cond_filters.append([s_field, "like", f"%{txt}%"]) barcodes = frappe.get_all( "Item Barcode", @@ -1590,7 +1590,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): barcodes = [d.item_code for d in barcodes] if barcodes: - or_cond_filters["name"] = ("in", barcodes) + or_cond_filters.append(["name", "in", barcodes]) if filters and filters.get("item_code"): has_variants = frappe.get_cached_value("Item", filters.get("item_code"), "has_variants") diff --git a/erpnext/manufacturing/doctype/bom/mapper.py b/erpnext/manufacturing/doctype/bom/mapper.py deleted file mode 100644 index f237172fb56..00000000000 --- a/erpnext/manufacturing/doctype/bom/mapper.py +++ /dev/null @@ -1,206 +0,0 @@ -# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -"""Document-mapping and query helpers for BOM (extracted from bom.py).""" - -from functools import partial - -import frappe -from frappe import _ -from frappe.core.doctype.version.version import get_diff -from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.query_builder import Field -from frappe.query_builder.functions import IfNull -from frappe.utils import today - -from erpnext.stock.doctype.item.item import get_item_details - -_BOM_DIFF_IDENTIFIERS = { - "operations": "operation", - "items": "item_code", - "secondary_items": "item_code", - "exploded_items": "item_code", -} - -_VARIANT_BOM_MAPPING = { - "BOM": {"doctype": "BOM", "validation": {"docstatus": ["=", 1]}}, - "BOM Item": { - "doctype": "BOM Item", - # stop get_mapped_doc copying parent bom_no to children - "field_no_map": ["bom_no"], - "condition": lambda doc: doc.has_variants == 0, - }, -} - - -@frappe.whitelist() -def get_children(parent: str | None = None, is_root: bool = False, **filters): - frappe.has_permission("BOM", "read", throw=True) - - if not parent or parent == "BOM": - frappe.msgprint(_("Please select a BOM")) - return - - frappe.form_dict.parent = parent - bom_doc = frappe.get_cached_doc("BOM", parent) - frappe.has_permission("BOM", doc=bom_doc, throw=True) - - bom_items = _bom_child_items(parent) - _enrich_bom_items(bom_items, bom_doc) - return bom_items - - -def _bom_child_items(parent): - return frappe.get_all( - "BOM Item", - fields=["item_code", "bom_no as value", "stock_qty", "qty", "is_phantom_item", "bom_no"], - filters=[["parent", "=", parent]], - order_by="idx", - ) - - -def _enrich_bom_items(bom_items, bom_doc): - item_names = tuple(d.get("item_code") for d in bom_items) - items = frappe.get_list( - "Item", - fields=["image", "description", "name", "stock_uom", "item_name", "is_sub_contracted_item"], - filters=[["name", "in", item_names]], - ) - for bom_item in bom_items: - bom_item.update(next(item for item in items if item.get("name") == bom_item.get("item_code"))) - bom_item.parent_bom_qty = bom_doc.quantity - bom_item.expandable = 0 if bom_item.value in ("", None) else 1 - bom_item.image = frappe.db.escape(bom_item.image) - - -@frappe.whitelist() -def get_bom_diff(bom1: str, bom2: str): - frappe.has_permission("BOM", "read", throw=True) - if bom1 == bom2: - frappe.throw( - _("BOM 1 {0} and BOM 2 {1} should not be the same").format(frappe.bold(bom1), frappe.bold(bom2)) - ) - - doc1 = frappe.get_doc("BOM", bom1) - doc2 = frappe.get_doc("BOM", bom2) - - out = get_diff(doc1, doc2) - out.row_changed, out.added, out.removed = [], [], [] - for df in doc1.meta.fields: - _diff_table_field(df, doc1, doc2, out) - return out - - -def _diff_table_field(df, doc1, doc2, out): - from frappe.model import table_fields - - if df.fieldtype not in table_fields: - return - - identifier = _BOM_DIFF_IDENTIFIERS[df.fieldname] - old_value, new_value = doc1.get(df.fieldname), doc2.get(df.fieldname) - old_map = {d.get(identifier): d for d in old_value} - new_map = {d.get(identifier): d for d in new_value} - - _collect_row_changes(df, identifier, old_map, new_value, out) - for d in old_value: - if d.get(identifier) not in new_map: - out.removed.append([df.fieldname, d.as_dict()]) - - -def _collect_row_changes(df, identifier, old_map, new_value, out): - for i, d in enumerate(new_value): - if d.get(identifier) not in old_map: - out.added.append([df.fieldname, d.as_dict()]) - continue - - diff = get_diff(old_map[d.get(identifier)], d, for_child=True) - if diff and diff.changed: - out.row_changed.append((df.fieldname, i, d.get(identifier), diff.changed)) - - -@frappe.whitelist() -@frappe.validate_and_sanitize_search_inputs -def item_query( - doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict | None = None -): - frappe.has_permission("Item", "read", throw=True) - - searchfields = frappe.get_meta("Item", cached=True).get_search_fields() - fields = ["name", "item_name", "item_group", "description"] - fields.extend(f for f in searchfields if f not in ["name", "item_group", "description"]) - - query_filters = _item_query_filters(filters) - or_filters = _item_query_or_filters(txt, searchfields or ["name"], query_filters) - return frappe.get_list( - "Item", - fields=fields, - filters=query_filters, - or_filters=or_filters, - order_by="idx desc, name, item_name", - limit_start=start, - limit_page_length=page_len, - as_list=1, - ) - - -def _item_query_filters(filters): - query_filters = [["disabled", "=", 0], [IfNull(Field("end_of_life"), "3099-12-31"), ">", today()]] - if filters and filters.get("item_code"): - if not frappe.get_cached_value("Item", filters.get("item_code"), "has_variants"): - query_filters.append(["has_variants", "=", 0]) - - for fieldname, value in (filters or {}).items(): - query_filters.append([fieldname, "=", value]) - return query_filters - - -def _item_query_or_filters(txt, searchfields, query_filters): - if not txt: - return [] - - or_filters = [[s_field, "like", f"%{txt}%"] for s_field in searchfields] - barcodes = frappe.get_all( - "Item Barcode", - fields=["parent as item_code"], - filters={"barcode": ("like", f"%{txt}%")}, - distinct=True, - ) - barcode_codes = [d.item_code for d in barcodes] - if barcode_codes: - or_filters.append(["name", "in", barcode_codes]) - return or_filters - - -@frappe.whitelist() -def make_variant_bom( - source_name: str, - bom_no: str, - item: str, - variant_items: str | list, - target_doc: str | dict | Document | None = None, -): - frappe.has_permission("BOM", "write", throw=True) - - postprocess = partial( - _postprocess_variant_bom, item=item, variant_items=variant_items, source_name=source_name - ) - return get_mapped_doc("BOM", source_name, _VARIANT_BOM_MAPPING, target_doc, postprocess) - - -def _postprocess_variant_bom(source, doc, item, variant_items, source_name): - from erpnext.manufacturing.doctype.work_order.work_order import add_variant_item - - item_data = get_item_details(item) - doc.item = item - doc.quantity = 1 - doc.update( - { - "item_name": item_data.item_name, - "description": item_data.description, - "uom": item_data.stock_uom, - "allow_alternative_item": item_data.allow_alternative_item, - } - ) - add_variant_item(variant_items, doc, source_name) From a3103469e2e2d4d60e263b0e438eaf60499b7146 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:01:41 +0530 Subject: [PATCH 46/60] fix(manufacturing): correct nested BOM quantities --- .../report/bom_explorer/bom_explorer.py | 10 +++- .../report/bom_explorer/test_bom_explorer.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index de6dec9ebb8..acc0c2be9b9 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -21,7 +21,7 @@ def get_exploded_items(bom, data, indent=0, qty=1): exploded_items = frappe.get_all( "BOM Item", filters={"parent": bom}, - fields=["qty", "bom_no", "qty", "item_code", "item_name", "description", "uom", "idx"], + fields=["qty", "bom_no", "stock_qty", "item_code", "item_name", "description", "uom", "idx"], order_by="idx ASC", ) @@ -40,7 +40,13 @@ def get_exploded_items(bom, data, indent=0, qty=1): } ) if item.bom_no: - get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty) + child_bom_qty = frappe.get_cached_value("BOM", item.bom_no, "quantity") + get_exploded_items( + item.bom_no, + data, + indent=indent + 1, + qty=qty * item.stock_qty / child_bom_qty, + ) def get_columns(): diff --git a/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py new file mode 100644 index 00000000000..29db243a316 --- /dev/null +++ b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import unittest +from unittest.mock import patch + +import frappe + +from erpnext.manufacturing.report.bom_explorer.bom_explorer import get_exploded_items + + +class TestBOMExplorer(unittest.TestCase): + def test_nested_bom_normalizes_and_accumulates_qty(self): + def item(item_code, qty, stock_qty, bom_no="", uom="Nos"): + return frappe._dict( + item_code=item_code, + item_name=item_code, + description="", + qty=qty, + stock_qty=stock_qty, + bom_no=bom_no, + uom=uom, + idx=1, + ) + + children = { + "root": [item("parent", 2, 20, "parent-bom", "Box")], + "parent-bom": [item("child", 3, 12, "child-bom", "Pack")], + "child-bom": [item("raw-material", 2, 2, uom="Kg")], + } + bom_quantities = {"parent-bom": 5, "child-bom": 4} + + def get_items(_doctype, filters, **kwargs): + return children[filters["parent"]] + + def get_bom_quantity(_doctype, name, _fieldname): + return bom_quantities[name] + + data = [] + with ( + patch.object(frappe, "get_all", side_effect=get_items), + patch.object(frappe, "get_cached_value", side_effect=get_bom_quantity), + ): + get_exploded_items("root", data) + + self.assertEqual([row["qty"] for row in data], [2, 12, 24]) From 5c2123f6ed34297ecfd68a5f6b345ad7fdb5c645 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:14:49 +0530 Subject: [PATCH 47/60] fix(manufacturing): avoid child BOM cache lookups --- .../report/bom_explorer/bom_explorer.py | 15 ++++++++++++--- .../report/bom_explorer/test_bom_explorer.py | 17 ++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index acc0c2be9b9..7fffed14866 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -21,7 +21,17 @@ def get_exploded_items(bom, data, indent=0, qty=1): exploded_items = frappe.get_all( "BOM Item", filters={"parent": bom}, - fields=["qty", "bom_no", "stock_qty", "item_code", "item_name", "description", "uom", "idx"], + fields=[ + "qty", + "bom_no", + "bom_no.quantity as child_bom_qty", + "stock_qty", + "item_code", + "item_name", + "description", + "uom", + "idx", + ], order_by="idx ASC", ) @@ -40,12 +50,11 @@ def get_exploded_items(bom, data, indent=0, qty=1): } ) if item.bom_no: - child_bom_qty = frappe.get_cached_value("BOM", item.bom_no, "quantity") get_exploded_items( item.bom_no, data, indent=indent + 1, - qty=qty * item.stock_qty / child_bom_qty, + qty=qty * item.stock_qty / item.child_bom_qty, ) diff --git a/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py index 29db243a316..b2cac10a434 100644 --- a/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py @@ -11,7 +11,7 @@ from erpnext.manufacturing.report.bom_explorer.bom_explorer import get_exploded_ class TestBOMExplorer(unittest.TestCase): def test_nested_bom_normalizes_and_accumulates_qty(self): - def item(item_code, qty, stock_qty, bom_no="", uom="Nos"): + def item(item_code, qty, stock_qty, bom_no="", uom="Nos", child_bom_qty=None): return frappe._dict( item_code=item_code, item_name=item_code, @@ -19,28 +19,23 @@ class TestBOMExplorer(unittest.TestCase): qty=qty, stock_qty=stock_qty, bom_no=bom_no, + child_bom_qty=child_bom_qty, uom=uom, idx=1, ) children = { - "root": [item("parent", 2, 20, "parent-bom", "Box")], - "parent-bom": [item("child", 3, 12, "child-bom", "Pack")], + "root": [item("parent", 2, 20, "parent-bom", "Box", 5)], + "parent-bom": [item("child", 3, 12, "child-bom", "Pack", 4)], "child-bom": [item("raw-material", 2, 2, uom="Kg")], } - bom_quantities = {"parent-bom": 5, "child-bom": 4} def get_items(_doctype, filters, **kwargs): + self.assertIn("bom_no.quantity as child_bom_qty", kwargs["fields"]) return children[filters["parent"]] - def get_bom_quantity(_doctype, name, _fieldname): - return bom_quantities[name] - data = [] - with ( - patch.object(frappe, "get_all", side_effect=get_items), - patch.object(frappe, "get_cached_value", side_effect=get_bom_quantity), - ): + with patch.object(frappe, "get_all", side_effect=get_items): get_exploded_items("root", data) self.assertEqual([row["qty"] for row in data], [2, 12, 24]) From 47c6274b139a34d71d0c4ca20f95129f2e021fca Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:25:59 +0530 Subject: [PATCH 48/60] fix(selling): bill re-delivered sales order quantities --- .../doctype/sales_order/sales_order.py | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 1dd62a65239..a040a8f396d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1114,6 +1114,13 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): return target_doc +def get_qty_net_of_returns(so_item) -> float: + """Return the ordered quantity billable after returns and re-deliveries.""" + qty = flt(so_item.qty) + + return min(qty, max(qty - flt(so_item.returned_qty), flt(so_item.delivered_qty))) + + @frappe.whitelist() def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): if args is None: @@ -1123,10 +1130,40 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a # 0 qty is accepted, as the qty is uncertain for some items has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + billed_qty_by_item = None + pending_qty_by_item = {} def is_unit_price_row(source): return has_unit_price_items and source.qty == 0 + def get_billed_qty_by_item(): + nonlocal billed_qty_by_item + + if billed_qty_by_item is None: + invoice_item = frappe.qb.DocType("Sales Invoice Item") + sales_order_item = frappe.qb.DocType("Sales Order Item") + rows = ( + frappe.qb.from_(invoice_item) + .inner_join(sales_order_item) + .on(invoice_item.so_detail == sales_order_item.name) + .select(invoice_item.so_detail, Sum(invoice_item.qty).as_("qty")) + .where((invoice_item.docstatus == 1) & (sales_order_item.parent == source_name)) + .groupby(invoice_item.so_detail) + ).run(as_dict=True) + billed_qty_by_item = {row.so_detail: flt(row.qty) for row in rows} + + return billed_qty_by_item + + def get_pending_qty(source): + if source.name not in pending_qty_by_item: + billable_qty = get_qty_net_of_returns(source) + if source.qty and source.billed_amt: + billable_qty -= get_billed_qty_by_item().get(source.name, 0) + + pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0) + + return pending_qty_by_item[source.name] + def postprocess(source, target): set_missing_values(source, target) # Get the advance paid Journal Entries in Sales Invoice Advance @@ -1156,17 +1193,6 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a target.debit_to = get_party_account("Customer", source.customer, source.company) def update_item(source, target, source_parent): - def get_billed_qty(so_item_name): - from frappe.query_builder.functions import Sum - - table = frappe.qb.DocType("Sales Invoice Item") - query = ( - frappe.qb.from_(table) - .select(Sum(table.qty).as_("qty")) - .where((table.docstatus == 1) & (table.so_detail == so_item_name)) - ) - return query.run(pluck="qty")[0] or 0 - if source_parent.has_unit_price_items: # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is pending_amount = flt(source.amount) - flt(source.billed_amt) @@ -1175,11 +1201,7 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a target.amount = flt(source.amount) - flt(source.billed_amt) target.base_amount = target.amount * flt(source_parent.conversion_rate) - target.qty = ( - source.qty - get_billed_qty(source.name) - if (source.qty and source.billed_amt) - else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) - ) + target.qty = source.qty if is_unit_price_row(source) else get_pending_qty(source) if source_parent.project: target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center") @@ -1215,12 +1237,16 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, a "parent": "sales_order", }, "postprocess": update_item, - "condition": lambda doc: ( + "condition": lambda doc: select_item(doc) + and ( True if is_unit_price_row(doc) - else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) - ) - and select_item(doc), + else ( + doc.qty + and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) + and get_pending_qty(doc) > 0 + ) + ), }, "Sales Taxes and Charges": { "doctype": "Sales Taxes and Charges", From 220b25db0963cebc79f1558e58c90cb48f69e146 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:26:00 +0530 Subject: [PATCH 49/60] test(selling): cover invoicing after returns and re-deliveries --- .../doctype/sales_order/test_sales_order.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 19b891e4ba5..b71e2d6878a 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -226,6 +226,95 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): si1 = make_sales_invoice(so.name) self.assertEqual(len(si1.get("items")), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so = make_sales_order(qty=10, rate=100) + dn = create_dn_against_so(so.name, 10) + + dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict()) + dn_return.insert() + dn_return.submit() + + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + create_dn_against_so(so.name, 10) + + so.load_from_db() + item = so.get("items")[0] + self.assertEqual(item.delivered_qty, 10) + self.assertEqual(item.returned_qty, 10) + + si = make_sales_invoice(so.name) + self.assertEqual(si.get("items")[0].qty, 10) + + def test_make_sales_invoice_bills_ordered_qty_for_partial_delivery(self): + so = make_sales_order(qty=10, rate=100) + create_dn_against_so(so.name, 4) + + si = make_sales_invoice(so.name) + self.assertEqual(si.get("items")[0].qty, 10) + + def test_make_sales_invoice_after_partial_billing_return_and_redelivery(self): + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + so = make_sales_order(qty=10, rate=100) + dn = create_dn_against_so(so.name, 10) + + si = make_sales_invoice(so.name) + si.get("items")[0].qty = 4 + si.insert() + si.submit() + + dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict()) + dn_return.insert() + dn_return.submit() + create_dn_against_so(so.name, 5) + + so.load_from_db() + item = so.get("items")[0] + self.assertEqual(item.delivered_qty, 5) + self.assertEqual(item.returned_qty, 10) + self.assertEqual(item.billed_amt, 400) + + pending_invoice = make_sales_invoice(so.name) + self.assertEqual(pending_invoice.get("items")[0].qty, 1) + pending_invoice.insert() + pending_invoice.submit() + + so.load_from_db() + self.assertEqual(so.get("items")[0].billed_amt, 500) + + def test_make_sales_invoice_after_partial_billing_multiple_items(self): + so = make_sales_order( + item_list=[ + { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + { + "item_code": "_Test FG Item", + "warehouse": "_Test Warehouse - _TC", + "qty": 10, + "rate": 100, + }, + ] + ) + + si = make_sales_invoice(so.name) + si.get("items")[0].qty = 4 + si.get("items")[1].qty = 6 + si.insert() + si.submit() + + pending_invoice = make_sales_invoice(so.name) + self.assertEqual( + {item.so_detail: item.qty for item in pending_invoice.get("items")}, + {so.get("items")[0].name: 6, so.get("items")[1].name: 4}, + ) + def test_so_billed_amount_against_return_entry(self): from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return From 2e5ae188d65a865db9c7973e05e6ad926fd05757 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:41:57 +0530 Subject: [PATCH 50/60] fix: require material transfer before job card time logs v15 adaptation of the develop fix: the timer flow here runs through add_time_log via make_time_log, so gate that instead of the start_timer/complete_job_card methods that only exist on develop, and hide Start Job while transfer is pending. Corrective job cards and job cards without items (transfer against Work Order) stay exempt, matching validate_transfer_qty on submit. --- erpnext/manufacturing/doctype/job_card/job_card.js | 9 +++++++++ erpnext/manufacturing/doctype/job_card/job_card.py | 2 ++ 2 files changed, 11 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index cc8bdf04176..52423c600d9 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -296,7 +296,16 @@ frappe.ui.form.on("Job Card", { prepare_timer_buttons: function (frm) { frm.trigger("make_dashboard"); + const transfer_pending = + !frm.doc.is_corrective_job_card && + (frm.doc.items || []).length && + flt(frm.doc.transferred_qty) < flt(frm.doc.for_quantity); + if (!frm.doc.started_time && !frm.doc.current_time) { + if (transfer_pending) { + return; + } + frm.add_custom_button(__("Start Job"), () => { if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { frappe.prompt( diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index 9fddeda3e96..b1854088975 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -513,6 +513,8 @@ class JobCard(Document): ) def add_time_log(self, args): + self.validate_transfer_qty() + last_row = [] employees = args.employees if isinstance(employees, str): From b70fa4fdd102549135d39fd3f31736b052fd709c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:41:59 +0530 Subject: [PATCH 51/60] test: job card time log blocked until material transfer --- .../doctype/job_card/test_job_card.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index a50d0cba62b..ceb5ce299eb 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -249,6 +249,26 @@ class TestJobCard(FrappeTestCase): # JC is Completed with excess transfer self.assertEqual(job_card.status, "Completed") + def test_job_card_time_log_blocked_until_material_transfer(self): + "Time logs must wait for the transfer when RMs move against Job Card." + self.transfer_material_against = "Job Card" + self.source_warehouse = "Stores - _TC" + + self.generate_required_stock(self.work_order) + job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) + + self.assertRaises( + frappe.ValidationError, job_card.add_time_log, frappe._dict(start_time=now(), employees=[]) + ) + + transfer_entry = make_stock_entry_from_jc(job_card.name) + transfer_entry.insert() + transfer_entry.submit() + + job_card.reload() + job_card.add_time_log(frappe._dict(start_time=now(), employees=[])) + self.assertTrue(job_card.time_logs) + @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0}) def test_job_card_excess_material_transfer_block(self): self.transfer_material_against = "Job Card" From ac4e9c34cf42727627f564fc6b486cfc2a4eb710 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 13:49:48 +0530 Subject: [PATCH 52/60] chore(selling): annotate make_sales_invoice arguments --- erpnext/selling/doctype/sales_order/sales_order.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index a040a8f396d..a825d08cc15 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -1122,7 +1122,12 @@ def get_qty_net_of_returns(so_item) -> float: @frappe.whitelist() -def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None): +def make_sales_invoice( + source_name: str, + target_doc: str | dict | Document | None = None, + ignore_permissions: bool = False, + args: str | dict | None = None, +): if args is None: args = {} if isinstance(args, str): From 2a4a9fff3dbecd74b0f7be1604d497913c60a04f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 15:09:48 +0530 Subject: [PATCH 53/60] fix(setup): fetch driver address by supplier link (cherry picked from commit 3ffb888d266031f8411030b4bc89bd2521c28fe4) --- erpnext/setup/doctype/driver/driver.js | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/erpnext/setup/doctype/driver/driver.js b/erpnext/setup/doctype/driver/driver.js index 35f8bff5874..3372b75bc35 100644 --- a/erpnext/setup/doctype/driver/driver.js +++ b/erpnext/setup/doctype/driver/driver.js @@ -23,15 +23,20 @@ frappe.ui.form.on("Driver", { }, transporter: function (frm, cdt, cdn) { - // this assumes that supplier's address has same title as supplier's name if (!frm.doc.transporter) return; - frappe.db - .get_doc("Address", null, { address_title: frm.doc.transporter }) - .then((r) => { - frappe.model.set_value(cdt, cdn, "address", r.name); - }) - .catch((err) => { - console.log(err); - }); + + const transporter = frm.doc.transporter; + frappe.call({ + method: "frappe.contacts.doctype.address.address.get_default_address", + args: { + doctype: "Supplier", + name: transporter, + }, + callback: function (r) { + if (frm.doc.transporter === transporter) { + frappe.model.set_value(cdt, cdn, "address", r.message); + } + }, + }); }, }); From d620720445dd7e1cddd4cf8c6467de3f56a4d51f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 16:33:53 +0530 Subject: [PATCH 54/60] fix(stock): validate warehouse accounts when used --- erpnext/setup/doctype/company/company.py | 1 + erpnext/stock/__init__.py | 24 ++++- .../purchase_receipt/purchase_receipt.py | 18 ++-- .../stock/doctype/warehouse/test_warehouse.py | 91 +++++++++++++++++++ erpnext/stock/doctype/warehouse/warehouse.py | 10 +- .../subcontracting_receipt.py | 10 +- 6 files changed, 135 insertions(+), 19 deletions(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index c53dfa5fb40..05b3ce7aa7b 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -346,6 +346,7 @@ class Company(NestedSet): ) warehouse.flags.ignore_permissions = True warehouse.flags.ignore_mandatory = True + warehouse.flags.ignore_inventory_account_validation = True warehouse.insert() if wh_detail["is_group"]: diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index aa556c62434..19d8bcd21e3 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -16,6 +16,17 @@ install_docs = [ ] +class WarehouseAccountMap(frappe._dict): + def __missing__(self, warehouse): + account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse)) + account_details = frappe._dict( + account=account, + account_currency=frappe.get_cached_value("Account", account, "account_currency"), + ) + self[warehouse] = account_details + return account_details + + def get_warehouse_account_map(company=None): company_warehouse_account_map = company and frappe.flags.setdefault("warehouse_account_map", {}).get( company @@ -23,7 +34,7 @@ def get_warehouse_account_map(company=None): warehouse_account_map = frappe.flags.warehouse_account_map if not warehouse_account_map or not company_warehouse_account_map or frappe.flags.in_test: - warehouse_account = frappe._dict() + warehouse_account = WarehouseAccountMap() filters = {} if company: @@ -37,7 +48,7 @@ def get_warehouse_account_map(company=None): order_by="lft, rgt", ): if not d.account: - d.account = get_warehouse_account(d, warehouse_account) + d.account = get_warehouse_account(d, warehouse_account, raise_error=False) if d.account: d.account_currency = frappe.db.get_value("Account", d.account, "account_currency", cache=True) @@ -47,10 +58,13 @@ def get_warehouse_account_map(company=None): else: frappe.flags.warehouse_account_map = warehouse_account - return frappe.flags.warehouse_account_map.get(company) or frappe.flags.warehouse_account_map + if company: + return frappe.flags.warehouse_account_map.get(company, WarehouseAccountMap()) + + return frappe.flags.warehouse_account_map -def get_warehouse_account(warehouse, warehouse_account=None): +def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True): account = warehouse.account if not account and warehouse.parent_warehouse: if warehouse_account: @@ -86,7 +100,7 @@ def get_warehouse_account(warehouse, warehouse_account=None): if len(inventory_accounts) == 1: account = inventory_accounts[0] - if not account and warehouse.company and not warehouse.is_group: + if raise_error and not account and warehouse.company and not warehouse.is_group: frappe.throw( _("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format( warehouse.name, warehouse.company diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 08fe7feff56..25c6fd987f5 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -609,7 +609,7 @@ class PurchaseReceipt(BuyingController): def make_sub_contracting_gl_entries(item): # sub-contracting warehouse - if flt(item.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse): + if flt(item.rm_supp_cost): self.add_gl_entry( gl_entries=gl_entries, account=supplier_warehouse_account, @@ -718,22 +718,22 @@ class PurchaseReceipt(BuyingController): stock_value_diff = ( flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount) ) - elif warehouse_account.get(d.warehouse): + elif d.warehouse: stock_value_diff = get_stock_value_difference(self.name, d.name, d.warehouse) stock_asset_account_name = warehouse_account[d.warehouse]["account"] - supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get( - "account" - ) - supplier_warehouse_account_currency = warehouse_account.get( - self.supplier_warehouse, {} - ).get("account_currency") + supplier_warehouse_details = warehouse_account.get(self.supplier_warehouse, {}) + if flt(d.rm_supp_cost): + supplier_warehouse_details = warehouse_account[self.supplier_warehouse] + + supplier_warehouse_account = supplier_warehouse_details.get("account") + supplier_warehouse_account_currency = supplier_warehouse_details.get("account_currency") # If PR is sub-contracted and fg item rate is zero # in that case if account for source and target warehouse are same, # then GL entries should not be posted if ( flt(stock_value_diff) == flt(d.rm_supp_cost) - and warehouse_account.get(self.supplier_warehouse) + and supplier_warehouse_account and stock_asset_account_name == supplier_warehouse_account ): continue diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 5b6f8f727fb..688ba94ef78 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -125,6 +125,70 @@ class TestWarehouse(FrappeTestCase): ) self.assertRaises(frappe.ValidationError, get_warehouse_account, warehouse) + def test_unrelated_warehouse_without_inventory_account_is_ignored(self): + from erpnext.stock import get_warehouse_account_map + + company, warehouse = create_ambiguous_inventory_account_warehouse() + warehouse_account_map = get_warehouse_account_map(company) + resolved_warehouse = next(iter(warehouse_account_map)) + + self.assertNotIn(warehouse.name, warehouse_account_map) + self.assertTrue(warehouse_account_map[resolved_warehouse].account) + + def test_direct_warehouse_account_map_lookup_remains_strict(self): + from erpnext.stock import get_warehouse_account_map + + company, warehouse = create_ambiguous_inventory_account_warehouse() + + with self.assertRaises(frappe.ValidationError): + get_warehouse_account_map(company)[warehouse.name] + + def test_new_warehouse_requires_inventory_account(self): + company, _warehouse = create_ambiguous_inventory_account_warehouse() + frappe.db.set_value("Company", company, "enable_perpetual_inventory", 1) + parent_warehouse = frappe.db.get_value("Warehouse", {"company": company, "is_group": 1}, "name") + frappe.db.set_value("Warehouse", parent_warehouse, "account", None) + warehouse = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "Missing Inventory Account", + "parent_warehouse": parent_warehouse, + "company": company, + } + ) + + self.assertRaises(frappe.ValidationError, warehouse.insert) + + def test_new_warehouse_can_inherit_inventory_account(self): + from erpnext.stock import get_warehouse_account + + company, _warehouse = create_ambiguous_inventory_account_warehouse() + frappe.db.set_value("Company", company, "enable_perpetual_inventory", 1) + parent_warehouse = frappe.db.get_value("Warehouse", {"company": company, "is_group": 1}, "name") + inventory_account = frappe.db.get_value( + "Account", {"company": company, "account_type": "Stock", "is_group": 0}, "name" + ) + frappe.db.set_value("Warehouse", parent_warehouse, "account", inventory_account) + + warehouse = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "Inherited Inventory Account", + "parent_warehouse": parent_warehouse, + "company": company, + } + ).insert() + + self.assertEqual(get_warehouse_account(warehouse), inventory_account) + + def test_warehouse_onload_allows_missing_inventory_account(self): + company, warehouse = create_ambiguous_inventory_account_warehouse() + frappe.db.set_value("Company", company, "enable_perpetual_inventory", 1) + + warehouse.run_method("onload") + + self.assertNotIn("account", warehouse.get_onload()) + def create_inventory_fallback_company(): company = "_Test Company Inventory Fallback" @@ -142,6 +206,33 @@ def create_inventory_fallback_company(): return company +def create_ambiguous_inventory_account_warehouse(): + company = create_inventory_fallback_company() + frappe.db.set_value("Company", company, "default_inventory_account", None) + + single_account = frappe.db.get_value( + "Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name" + ) + warehouses = frappe.get_all( + "Warehouse", filters={"company": company, "is_group": 0}, pluck="name", order_by="name" + ) + for warehouse_name in warehouses: + frappe.db.set_value("Warehouse", warehouse_name, "account", single_account) + + warehouse = frappe.get_doc("Warehouse", warehouses[0]) + warehouse.db_set({"account": None, "disabled": 0}) + + if not frappe.db.exists("Account", "Extra Inventory Account - _TCIF"): + create_account( + account_name="Extra Inventory Account", + parent_account=frappe.db.get_value("Account", single_account, "parent_account"), + account_type="Stock", + company=company, + ) + + return company, warehouse + + def create_warehouse(warehouse_name, properties=None, company=None): if not company: company = "_Test Company" diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index b9600fddc9b..9451228716e 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -52,10 +52,18 @@ class Warehouse(NestedSet): self.name = self.warehouse_name + def before_insert(self): + if ( + self.company + and not self.flags.ignore_inventory_account_validation + and frappe.get_cached_value("Company", self.company, "enable_perpetual_inventory") + ): + get_warehouse_account(self, get_warehouse_account_map(self.company)) + def onload(self): """load account name for General Ledger Report""" if self.company and cint(frappe.db.get_value("Company", self.company, "enable_perpetual_inventory")): - account = self.account or get_warehouse_account(self) + account = self.account or get_warehouse_account(self, raise_error=False) if account: self.set_onload("account", account) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 8be5f453632..501fde5b3dd 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -633,7 +633,7 @@ class SubcontractingReceipt(SubcontractingController): for item in self.items: if flt(item.rate) and flt(item.qty): - if warehouse_account and warehouse_account.get(item.warehouse): + if warehouse_account is not None: stock_value_diff = frappe.db.get_value( "Stock Ledger Entry", { @@ -647,9 +647,11 @@ class SubcontractingReceipt(SubcontractingController): ) accepted_warehouse_account = warehouse_account[item.warehouse]["account"] - supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get( - "account" - ) + supplier_warehouse_details = warehouse_account.get(self.supplier_warehouse, {}) + if flt(item.rm_supp_cost): + supplier_warehouse_details = warehouse_account[self.supplier_warehouse] + + supplier_warehouse_account = supplier_warehouse_details.get("account") remarks = self.get("remarks") or _("Accounting Entry for Stock") # Accepted Warehouse Account (Debit) From 5e3d0947c8a3b27844242d557f8a1989fd3ea6e2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:26:08 +0530 Subject: [PATCH 55/60] fix(stock): validate new warehouse inventory account after naming Move the insert-time check from before_insert to validate. before_insert runs before set_new_name, so the validation message rendered the warehouse name as None. validate runs after naming and only applies to new documents via is_new(). Resolve inheritance through the parent's lft/rgt bounds instead of the request-cached warehouse account map. The cached map can be stale within a request (a parent created moments earlier is missing from it), which made get_warehouse_account trigger a full nested-set rebuild_tree and could falsely reject a child whose parent carries a valid account. rebuild_tree enables auto_commit_on_many_writes, which must not run inside a document insert. --- erpnext/stock/doctype/warehouse/warehouse.py | 28 ++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 9451228716e..e92b617600e 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -52,14 +52,6 @@ class Warehouse(NestedSet): self.name = self.warehouse_name - def before_insert(self): - if ( - self.company - and not self.flags.ignore_inventory_account_validation - and frappe.get_cached_value("Company", self.company, "enable_perpetual_inventory") - ): - get_warehouse_account(self, get_warehouse_account_map(self.company)) - def onload(self): """load account name for General Ledger Report""" if self.company and cint(frappe.db.get_value("Company", self.company, "enable_perpetual_inventory")): @@ -70,8 +62,28 @@ class Warehouse(NestedSet): load_address_and_contact(self) def validate(self): + self.validate_inventory_account() self.warn_about_multiple_warehouse_account() + def validate_inventory_account(self): + if ( + not self.is_new() + or not self.company + or self.flags.ignore_inventory_account_validation + or not frappe.get_cached_value("Company", self.company, "enable_perpetual_inventory") + ): + return + + warehouse = frappe._dict(self.as_dict()) + if not self.account and self.parent_warehouse: + parent_bounds = frappe.db.get_value( + "Warehouse", self.parent_warehouse, ["lft", "rgt"], as_dict=True + ) + if parent_bounds: + warehouse.update(parent_bounds) + + get_warehouse_account(warehouse) + def on_update(self): self.update_nsm_model() From da2c422bf67d0ba2d4c3618b8e93db9c30493367 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:26:10 +0530 Subject: [PATCH 56/60] test(stock): cover named validation error and same-transaction parent inheritance --- .../stock/doctype/warehouse/test_warehouse.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 688ba94ef78..051fb14e49d 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -157,7 +157,7 @@ class TestWarehouse(FrappeTestCase): } ) - self.assertRaises(frappe.ValidationError, warehouse.insert) + self.assertRaisesRegex(frappe.ValidationError, "Missing Inventory Account - _TCIF", warehouse.insert) def test_new_warehouse_can_inherit_inventory_account(self): from erpnext.stock import get_warehouse_account @@ -181,6 +181,37 @@ class TestWarehouse(FrappeTestCase): self.assertEqual(get_warehouse_account(warehouse), inventory_account) + def test_new_warehouse_inherits_from_parent_created_in_same_transaction(self): + from erpnext.stock import get_warehouse_account + + company, _warehouse = create_ambiguous_inventory_account_warehouse() + frappe.db.set_value("Company", company, "enable_perpetual_inventory", 1) + root_warehouse = frappe.db.get_value("Warehouse", {"company": company, "is_group": 1}, "name") + inventory_account = frappe.db.get_value( + "Account", {"company": company, "account_type": "Stock", "is_group": 0}, "name" + ) + + parent_warehouse = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "New Parent Warehouse", + "parent_warehouse": root_warehouse, + "company": company, + "is_group": 1, + "account": inventory_account, + } + ).insert() + child_warehouse = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": "New Child Warehouse", + "parent_warehouse": parent_warehouse.name, + "company": company, + } + ).insert() + + self.assertEqual(get_warehouse_account(child_warehouse), inventory_account) + def test_warehouse_onload_allows_missing_inventory_account(self): company, warehouse = create_ambiguous_inventory_account_warehouse() frappe.db.set_value("Company", company, "enable_perpetual_inventory", 1) From 5a61ea64961c860cda80c77f7439dea737e6d6cf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 11 Aug 2026 18:58:49 +0530 Subject: [PATCH 57/60] test(stock): isolate warehouse account fixtures from class-level state FrappeTestCase on this branch rolls back per class, not per test, so sibling tests leak state. test_new_warehouse_can_inherit_inventory_account left an explicit account on the root group, which made later ambiguous fixtures resolve through the root: the insert validation stopped raising and the unresolved warehouse stayed in the map. The fixture helper now clears group warehouse accounts so every call re-establishes ambiguity. The fallback test also clears the account of the warehouse it picks, since a leftover explicit account skips the single-account fallback it asserts. --- erpnext/stock/doctype/warehouse/test_warehouse.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 051fb14e49d..4417e89a882 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -112,6 +112,7 @@ class TestWarehouse(FrappeTestCase): frappe.delete_doc("Account", "Extra Inventory Account - _TCIF") warehouse = frappe.get_doc("Warehouse", {"company": company, "is_group": 0}) + warehouse.db_set("account", None) single_account = frappe.db.get_value( "Account", {"account_type": "Stock", "is_group": 0, "company": company}, "name" ) @@ -250,6 +251,11 @@ def create_ambiguous_inventory_account_warehouse(): for warehouse_name in warehouses: frappe.db.set_value("Warehouse", warehouse_name, "account", single_account) + for group_warehouse in frappe.get_all( + "Warehouse", filters={"company": company, "is_group": 1}, pluck="name" + ): + frappe.db.set_value("Warehouse", group_warehouse, "account", None) + warehouse = frappe.get_doc("Warehouse", warehouses[0]) warehouse.db_set({"account": None, "disabled": 0}) From 01372cf295b3b1532f1f8af33549f73c1dee75d5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:54:04 +0000 Subject: [PATCH 58/60] fix(selling): reset stale item details on item change (backport #58051) (#58052) --- erpnext/public/js/controllers/transaction.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 88dc01d5845..e7f4cdec979 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -561,9 +561,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var update_stock = 0, show_batch_dialog = 0; item.weight_per_unit = 0; item.weight_uom = ''; - if(!item.barcode){ - item.uom = null // make UOM blank to update the existing UOM when item changes - } + item.uom = null // make UOM blank to update the existing UOM when item changes item.conversion_factor = 0; if(['Sales Invoice', 'Purchase Invoice'].includes(this.frm.doc.doctype)) { From 00d8dc73b90d16dec271826f0346d50ceeeaf69b Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Wed, 12 Aug 2026 02:46:40 +0530 Subject: [PATCH 59/60] fix(coa_importer): allow importing COA through `import_coa` only for `Accounts Manager` (backport #56132) (#58064) Co-authored-by: Pratheep S --- .../chart_of_accounts_importer/chart_of_accounts_importer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index eeaa5abfc6d..a50fd783d30 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -75,7 +75,10 @@ def validate_company(company): @frappe.whitelist() def import_coa(file_name, company): + frappe.only_for("Accounts Manager") + # delete existing data for accounts + frappe.has_permission("Company", "write", company, throw=True) unset_existing_data(company) # create accounts @@ -452,6 +455,7 @@ def unset_existing_data(company): fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", []) linked = [{"fieldname": name} for name in fieldnames] update_values = {d.get("fieldname"): "" for d in linked} + frappe.db.set_value("Company", company, update_values, update_values) # remove accounts data from various doctypes From d0ef9678900c582075c898787c7bf79a4cfb32d0 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Wed, 12 Aug 2026 04:24:34 +0530 Subject: [PATCH 60/60] fix(coa_importer): added server-side validations for importing chart of accounts (backport #58065) (#58067) --- .../chart_of_accounts_importer.js | 12 -------- .../chart_of_accounts_importer.py | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js index 1d8bb853083..b7200883124 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.js @@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", { args: { company: frm.doc.company, }, - callback: function (r) { - if (r.message === false) { - frm.set_value("company", ""); - frappe.throw( - __( - "Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions." - ) - ); - } else { - frm.trigger("refresh"); - } - }, }); } }, diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index a50fd783d30..4949d407cf4 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -70,7 +70,13 @@ def validate_company(company): frappe.throw(msg, title=_("Wrong Company")) if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1): - return False + frappe.throw( + _( + "Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions." + ) + ) + + validate_user_perms(company) @frappe.whitelist() @@ -79,16 +85,22 @@ def import_coa(file_name, company): # delete existing data for accounts frappe.has_permission("Company", "write", company, throw=True) - unset_existing_data(company) # create accounts file_doc, extension = get_file(file_name) + validate_accounts(file_doc, extension) if extension == "csv": data = generate_data_from_csv(file_doc) else: data = generate_data_from_excel(file_doc, extension) + validate_columns(data) + + validate_company(company) + + unset_existing_data(company) + frappe.local.flags.ignore_root_company_validation = True forest = build_forest(data) create_charts(company, custom_chart=forest, from_coa_importer=True) @@ -471,6 +483,19 @@ def unset_existing_data(company): frappe.qb.from_(dt).where(dt.company == company).delete().run() +def validate_user_perms(company): + # User Permission Check for Account Deletion + company_accounts = frappe.get_query("Account", filters={"company": company}).run(as_dict=1) + + for d in company_accounts: + if not frappe.get_cached_doc("Account", d.name).has_permission(): + frappe.throw( + _( + "Accounts cannot be removed, as user doesn't have access to all the accounts of {0}." + ).format(frappe.bold(company)) + ) + + def set_default_accounts(company): from erpnext.setup.doctype.company.company import install_country_fixtures