From c6176500d269cba563b3d00ae5e53ed8f0a5f375 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 10:49:27 +0530 Subject: [PATCH 01/52] fix: prefetch batchwise valuations before streaming SLEs in stock ageing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stock Ageing iterates stock ledger entries through an unbuffered (streaming) cursor. _get_batchwise_valuation() lazily queried Batch.use_batchwise_valuation from inside that loop whenever a row carried the legacy batch_no field, and the nested query invalidated the active streaming result set — crashing the report (or silently dropping the remaining rows, depending on the driver version). Resolve the valuation flags in a single query before entering the unbuffered cursor block; the lazy lookup now only serves callers that pass stock ledger entries in directly, where no streaming is active. Fixes https://github.com/frappe/erpnext/issues/55786 Co-Authored-By: Claude Fable 5 (cherry picked from commit 060a5c4eeb1cf38b9fb5726f4a98182c6a229bbc) --- .../stock/report/stock_ageing/stock_ageing.py | 31 ++++++++ .../report/stock_ageing/test_stock_ageing.py | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 238b72fa957..d0e96d640fe 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -306,6 +306,11 @@ class FIFOSlots: # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() + if stock_ledger_entries is None: + # nested queries invalidate the streaming cursor below, + # so batchwise valuation flags must be resolved beforehand + self._prefetch_batchwise_valuations() + with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: stock_ledger_entries = self._get_stock_ledger_entries() @@ -423,12 +428,38 @@ class FIFOSlots: def _get_batchwise_valuation(self, batch_no: str): if batch_no not in self.batchwise_valuation_by_batch: + # only reachable when stock ledger entries are passed in directly; + # the streaming path prefetches all flags before iteration self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value( "Batch", batch_no, "use_batchwise_valuation" ) return self.batchwise_valuation_by_batch[batch_no] + def _prefetch_batchwise_valuations(self) -> None: + sle = frappe.qb.DocType("Stock Ledger Entry") + batch = frappe.qb.DocType("Batch") + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + + query = ( + frappe.qb.from_(sle) + .left_join(batch) + .on(sle.batch_no == batch.name) + .select(sle.batch_no, batch.use_batchwise_valuation) + .distinct() + .where( + (sle.batch_no.isnotnull()) + & (sle.company == self.filters.get("company")) + & (sle.posting_datetime <= to_date) + & (sle.is_cancelled != 1) + ) + ) + + query = self._apply_filter(query, sle, "item_code") + + for batch_no, use_batchwise_valuation in query.run(): + self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 1c61306125e..59550f07aac 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -1438,6 +1438,80 @@ class TestStockAgeing(FrappeTestCase): item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] ) + def test_legacy_batch_no_sle_with_streaming_cursor(self): + """SLEs carrying the legacy batch_no field must not trigger nested + queries while entries stream through an unbuffered cursor.""" + from unittest.mock import patch + + from frappe.utils import add_days, nowdate + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + suffix = frappe.generate_hash(length=8).upper() + item_code = make_item( + f"Test Stock Ageing Legacy Batch {suffix}", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": f"SA-LEG-{suffix}-.###", + "valuation_method": "FIFO", + }, + ).name + warehouse = "_Test Warehouse - _TC" + base_date = nowdate() + + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=10, + posting_date=add_days(base_date, -2), + posting_time="10:00:00", + ) + batch_no = get_batch_from_bundle(reco.items[0].serial_and_batch_bundle) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=10, + batch_no=batch_no, + posting_date=add_days(base_date, -1), + posting_time="10:00:00", + ) + + # mimic pre-bundle data where SLEs carry batch_no directly + frappe.db.set_value( + "Stock Ledger Entry", + {"item_code": item_code}, + "batch_no", + batch_no, + ) + + filters = frappe._dict( + company="_Test Company", + to_date=base_date, + ranges=["30", "60", "90"], + item_code=item_code, + ) + fifo_slots = FIFOSlots(filters) + + # fetch row by row so the streaming result set is still active + # while each stock ledger entry is processed + with patch("frappe.database.database.SQL_ITERATOR_BATCH_SIZE", 1): + slots = fifo_slots.generate() + + self.assertEqual(fifo_slots.batchwise_valuation_by_batch.get(batch_no), 1) + self.assertEqual(slots[item_code]["total_qty"], 5.0) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" From 02e38e80a7fe964b03f470dc58e44ff202483f66 Mon Sep 17 00:00:00 2001 From: Mohammad Umair Sayed Date: Thu, 11 Jun 2026 11:50:00 +0530 Subject: [PATCH 02/52] fix(bom): fetch routing operations when Routing is selected (#55813) fix(bom): fetch routing operations when routing is selected frm.doc.operations is always an array in Frappe, so !frm.doc.operations was always false (empty array [] is truthy in JS), causing get_routing() to never fire when a Routing is selected on a BOM with no existing operations. Changed the guard to !frm.doc.operations.length so the fetch triggers correctly when the operations table is empty. Also wired the same fetch into the with_operations handler so that enabling the checkbox after a Routing is already set will populate operations without requiring the user to re-select the Routing. Co-authored-by: Umair Sayed (cherry picked from commit 9249fa89aa6bd846b94292ca22e1b6bdf6d4e4c8) # Conflicts: # erpnext/manufacturing/doctype/bom/bom.js --- erpnext/manufacturing/doctype/bom/bom.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 06b329e169e..b72fca2676b 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -75,6 +75,13 @@ frappe.ui.form.on("BOM", { with_operations: function (frm) { frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0); +<<<<<<< HEAD +======= + frm.trigger("toggle_fields_for_semi_finished_goods"); + if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { + frm.trigger("routing"); + } +>>>>>>> 9249fa89aa (fix(bom): fetch routing operations when Routing is selected (#55813)) }, fg_based_operating_cost: function (frm) { @@ -438,7 +445,7 @@ frappe.ui.form.on("BOM", { }, routing(frm) { - if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations) { + if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { frappe.call({ doc: frm.doc, method: "get_routing", From c03a66a1bf48a53c42d01c9d936d9b22aa013e11 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:53:33 +0530 Subject: [PATCH 03/52] fix: apply user permissions to receivable/payable reports (cherry picked from commit b05abbc53b3655b02db17ba2e8165519f195c1c2) --- .../accounts_receivable.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index d28f886a4fd..e83311647b2 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -922,8 +922,28 @@ class ReceivablePayableReport: if self.filters.project: self.qb_selection_filter.append(self.ple.project.isin(self.filters.project)) + self.add_user_permission_filters() + self.add_accounting_dimensions_filters() + def add_user_permission_filters(self): + # Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions + from frappe.core.doctype.user_permission.user_permission import get_user_permissions + from frappe.permissions import get_allowed_docs_for_doctype + + user_permissions = get_user_permissions() + if not user_permissions: + return + + for party_type in self.party_type: + if party_type not in user_permissions: + continue + + allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type) + self.qb_selection_filter.append( + (self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""]) + ) + def get_cost_center_conditions(self): cost_center_list = get_cost_centers_with_children(self.filters.cost_center) self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list)) From b9a694bb37fb4f72167594580465c83cb6bb0f83 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:58:21 +0530 Subject: [PATCH 04/52] test: cover user permission scoping in receivable report (cherry picked from commit 4200d17c9b208c72749e426a66a09cb48aeadbd9) --- .../test_accounts_receivable.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 93130fa353a..e48883660e6 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1253,3 +1253,44 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 1) row = report[1][0] self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding]) + + def test_accounts_receivable_respects_user_permissions(self): + # Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer + # must be applied explicitly. The report should only show permitted customers. + original_customer = self.customer + second_customer = "_Test AR Perm Customer" + + # create_customer overrides self.customer, so build the restricted invoice first + self.create_customer(customer_name=second_customer) + self.create_sales_invoice(no_payment_schedule=True) + + self.customer = original_customer + allowed_invoice = self.create_sales_invoice(no_payment_schedule=True) + + test_user = "test_ar_user_permission@example.com" + if not frappe.db.exists("User", test_user): + user = frappe.new_doc("User") + user.email = test_user + user.first_name = "AR Perm" + user.append("roles", {"role": "Accounts User"}) + user.save() + + frappe.permissions.add_user_permission("Customer", original_customer, test_user) + + filters = { + "company": self.company, + "party_type": "Customer", + "report_date": today(), + "range": "30, 60, 90, 120", + } + + frappe.set_user(test_user) + try: + report = execute(filters) + finally: + frappe.set_user("Administrator") + + parties = {row.party for row in report[1]} + self.assertIn(original_customer, parties) + self.assertNotIn(second_customer, parties) + self.assertEqual(allowed_invoice.customer, original_customer) From f1fc9e3261a154a9f97c4a3354573c78432e8f33 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 07:08:59 +0000 Subject: [PATCH 05/52] fix: added doctype filter validation for sales person wise transaction summary report (backport #55812) (#55817) Co-authored-by: Diptanil Saha fix: added doctype filter validation for sales person wise transaction summary report (#55812) --- .../sales_person_wise_transaction_summary.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index f8cde141fe4..405159215cd 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -13,6 +13,8 @@ def execute(filters=None): if not filters: filters = {} + validate_filters(filters) + columns = get_columns(filters) entries = get_entries(filters) item_details = get_item_details() @@ -49,10 +51,17 @@ def execute(filters=None): return columns, data -def get_columns(filters): +def validate_filters(filters): + ALLOWED_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note"] + if not filters.get("doc_type"): msgprint(_("Please select the document type first"), raise_exception=1) + if filters.get("doc_type") not in ALLOWED_DOCTYPES: + frappe.throw(_("{0}, {1} or {2} are the only allowed options.").format(*ALLOWED_DOCTYPES)) + + +def get_columns(filters): columns = [ { "label": _(filters["doc_type"]), From 7639a3360e2eab2683c37e040a51d1292dd5e856 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:04:15 +0530 Subject: [PATCH 06/52] test: clean up receivable entries to avoid cross-report leakage --- .../accounts_receivable/test_accounts_receivable.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index e48883660e6..4a73d62ee2e 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1257,6 +1257,15 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): def test_accounts_receivable_respects_user_permissions(self): # Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer # must be applied explicitly. The report should only show permitted customers. + + # Running the report writes an access log that commits, so these invoices survive + # tearDown's rollback. Delete and commit them so they don't leak into other tests. + def remove_committed_entries(): + self.clear_old_entries() + frappe.db.commit() # nosemgrep + + self.addCleanup(remove_committed_entries) + original_customer = self.customer second_customer = "_Test AR Perm Customer" From b8d507e4961eed892e54c3ad1b082af1d99bd15e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 11 Jun 2026 20:28:31 +0530 Subject: [PATCH 07/52] chore: resolve conflicts --- erpnext/manufacturing/doctype/bom/bom.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index b72fca2676b..52056c4bc5c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -75,13 +75,10 @@ frappe.ui.form.on("BOM", { with_operations: function (frm) { frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0); -<<<<<<< HEAD -======= frm.trigger("toggle_fields_for_semi_finished_goods"); if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { frm.trigger("routing"); } ->>>>>>> 9249fa89aa (fix(bom): fetch routing operations when Routing is selected (#55813)) }, fg_based_operating_cost: function (frm) { From f37727d39967329912e44b6a48f9134d97b51718 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 11 Jun 2026 20:28:55 +0530 Subject: [PATCH 08/52] chore: resolve conflicts --- erpnext/manufacturing/doctype/bom/bom.js | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 52056c4bc5c..e83f7e232ad 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -75,7 +75,6 @@ frappe.ui.form.on("BOM", { with_operations: function (frm) { frm.set_df_property("fg_based_operating_cost", "hidden", frm.doc.with_operations ? 1 : 0); - frm.trigger("toggle_fields_for_semi_finished_goods"); if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) { frm.trigger("routing"); } From 559585fb7b4f8651d0d799c4059e22f88edddeed Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 11 Jun 2026 13:13:09 +0530 Subject: [PATCH 09/52] fix: show user disable audit log (cherry picked from commit 73d1852773706efb6ec6305fd547f9c21c2643c3) --- erpnext/setup/doctype/employee/employee.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 812613bb6af..4640e909a5d 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -64,15 +64,8 @@ class Employee(NestedSet): ) def validate_user_details(self): - if self.user_id: - data = frappe.db.get_value("User", self.user_id, ["enabled"], as_dict=1) - - if not data: - self.user_id = None - return - - self.validate_for_enabled_user_id(data.get("enabled", 0)) - self.validate_duplicate_user_id() + self.validate_for_enabled_user_id() + self.validate_duplicate_user_id() def update_nsm_model(self): frappe.utils.nestedset.update_nsm(self) @@ -184,12 +177,15 @@ class Employee(NestedSet): if not self.relieving_date: throw(_("Please enter relieving date.")) - def validate_for_enabled_user_id(self, enabled): - if enabled is None: + def validate_for_enabled_user_id(self): + if not frappe.db.exists("User", self.user_id): frappe.throw(_("User {0} does not exist").format(self.user_id)) + user = frappe.get_doc("User", self.user_id) + enabled = user.enabled if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: - frappe.db.set_value("User", self.user_id, "enabled", not enabled) + user.enabled = not enabled + user.save(ignore_permissions=True) def validate_duplicate_user_id(self): Employee = frappe.qb.DocType("Employee") From e40999c8792c542951d6292df0a547cbd1555cb7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 11 Jun 2026 20:58:35 +0530 Subject: [PATCH 10/52] fix: sync employee user status after save --- erpnext/setup/doctype/employee/employee.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 4640e909a5d..7f6b78f4cb0 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -64,6 +64,9 @@ class Employee(NestedSet): ) def validate_user_details(self): + if not self.user_id: + return + self.validate_for_enabled_user_id() self.validate_duplicate_user_id() @@ -76,6 +79,7 @@ class Employee(NestedSet): if self.user_id: self.update_user() self.update_user_permissions() + self.update_user_status() self.reset_employee_emails_cache() def update_user_permissions(self): @@ -181,10 +185,15 @@ class Employee(NestedSet): if not frappe.db.exists("User", self.user_id): frappe.throw(_("User {0} does not exist").format(self.user_id)) + def update_user_status(self): + if not self.user_id: + return + user = frappe.get_doc("User", self.user_id) enabled = user.enabled if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: user.enabled = not enabled + # Keep linked User status in sync from the Employee lifecycle and record the audit log. user.save(ignore_permissions=True) def validate_duplicate_user_id(self): From c3a3eb3df313f07c2df7c72688fb44f9ca6cc829 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 11 Jun 2026 23:03:27 +0530 Subject: [PATCH 11/52] fix(stock): make uom mandatory in item uom table (cherry picked from commit a0177fdbe8db5b1217cf4747ac25c7dafb61acbe) --- .../uom_conversion_detail/uom_conversion_detail.json | 6 +++--- .../doctype/uom_conversion_detail/uom_conversion_detail.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json index 6f28651b99b..ead7db1e24e 100644 --- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json +++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.json @@ -32,7 +32,7 @@ "print_hide_if_no_value": 0, "read_only": 0, "report_hide": 0, - "reqd": 0, + "reqd": 1, "search_index": 0, "set_only_once": 0, "unique": 0 @@ -74,7 +74,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2016-07-11 03:28:09.626948", + "modified": "2026-06-11 23:02:54.800673", "modified_by": "Administrator", "module": "Stock", "name": "UOM Conversion Detail", @@ -84,4 +84,4 @@ "read_only": 0, "read_only_onload": 0, "track_seen": 0 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py index d73ba65ca95..3944e899029 100644 --- a/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py +++ b/erpnext/stock/doctype/uom_conversion_detail/uom_conversion_detail.py @@ -18,7 +18,7 @@ class UOMConversionDetail(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data - uom: DF.Link | None + uom: DF.Link # end: auto-generated types pass From 808ca06801f0b20dffdb0a4ffc26fecadf2b8ff2 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 12 Jun 2026 13:01:44 +0530 Subject: [PATCH 12/52] fix: permissions in workstation file (cherry picked from commit cf127e89005fd45038870bac95feb7009e95f45f) # Conflicts: # erpnext/manufacturing/doctype/workstation/workstation.py (cherry picked from commit 0fea93388d70106071672fa76c89ac98b2f51773) # Conflicts: # erpnext/manufacturing/doctype/workstation/workstation.py --- .../doctype/workstation/workstation.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 510e69cd272..3a60af341d9 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -65,7 +65,13 @@ class Workstation(Document): # end: auto-generated types def before_save(self): +<<<<<<< HEAD self.set_data_based_on_workstation_type() +======= + if self.has_value_changed("workstation_type"): + self._set_data_based_on_workstation_type() + +>>>>>>> 0fea93388d (fix: permissions in workstation file) self.set_hour_rate() self.set_total_working_hours() @@ -92,6 +98,10 @@ class Workstation(Document): @frappe.whitelist() def set_data_based_on_workstation_type(self): + self.check_permission("write") + self._set_data_based_on_workstation_type() + + def _set_data_based_on_workstation_type(self): if self.workstation_type: fields = [ "hour_rate_labour", @@ -168,6 +178,8 @@ class Workstation(Document): @frappe.whitelist() def start_job(self, job_card, from_time, employee): doc = frappe.get_doc("Job Card", job_card) + doc.check_permission("write") + doc.append("time_logs", {"from_time": from_time, "employee": employee}) doc.save(ignore_permissions=True) @@ -176,6 +188,8 @@ class Workstation(Document): @frappe.whitelist() def complete_job(self, job_card, qty, to_time): doc = frappe.get_doc("Job Card", job_card) + doc.check_permission("submit") + for row in doc.time_logs: if not row.to_time: row.to_time = to_time @@ -254,11 +268,24 @@ def get_status_color(status): return color_map.get(status, "var(--bg-blue)") +<<<<<<< HEAD def get_raw_materials(job_cards): raw_materials = {} data = frappe.get_all( "Job Card Item", +======= +@frappe.whitelist() +<<<<<<< HEAD +def get_raw_materials(job_card): +======= +def get_raw_materials(job_card: str): + frappe.has_permission("Job Card", "read", doc=job_card, throw=True) + +>>>>>>> cf127e8900 (fix: permissions in workstation file) + raw_materials = frappe.get_all( + "Job Card", +>>>>>>> 0fea93388d (fix: permissions in workstation file) fields=[ "parent", "item_code", @@ -364,6 +391,8 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime): @frappe.whitelist() def get_workstations(**kwargs): + frappe.has_permission("Workstation", "read", throw=True) + kwargs = frappe._dict(kwargs) _workstation = frappe.qb.DocType("Workstation") @@ -409,3 +438,79 @@ def get_workstations(**kwargs): d.status_image = d.off_status_image return data +<<<<<<< HEAD +======= + + +def get_color_map(): + return { + "Production": "green", + "Off": "gray", + "Idle": "gray", + "Problem": "red", + "Maintenance": "yellow", + "Setup": "blue", + } + + +ALLOWED_JOB_CARD_METHODS = frozenset( + { + "start_timer", + "pause_job", + "resume_job", + "complete_job_card", + } +) + + +@frappe.whitelist() +def update_job_card(job_card: str, method: str, **kwargs): + if method not in ALLOWED_JOB_CARD_METHODS: + frappe.throw( + _("Method {0} is not allowed to be run on a Job Card.").format(bold(method)), + frappe.PermissionError, + title=_("Not Allowed"), + ) + + doc = frappe.get_doc("Job Card", job_card) + doc.check_permission("write") + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + if kwargs.get("employees"): + kwargs.employees = frappe.parse_json(kwargs.employees) + + if kwargs.qty and isinstance(kwargs.qty, str): + kwargs.qty = flt(kwargs.qty) + + doc.run_method(method, **kwargs) + + +@frappe.whitelist() +<<<<<<< HEAD +def validate_job_card(job_card, status): +======= +def validate_job_card(job_card: str, status: str): + frappe.has_permission("Job Card", "read", doc=job_card, throw=True) + +>>>>>>> cf127e8900 (fix: permissions in workstation file) + job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) + + current_status = job_card_details.status + if current_status != status: + if status == "Open": + frappe.throw( + _("The job card {0} is in {1} state and you cannot start it again.").format( + job_card, current_status + ) + ) + else: + frappe.throw( + _("The job card {0} is in {1} state and you cannot complete.").format( + job_card, current_status + ) + ) + + return job_card_details.for_quantity +>>>>>>> 0fea93388d (fix: permissions in workstation file) From a65629da1aa08ff0fa73795dc8297aed75b2ef4c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 12 Jun 2026 16:51:57 +0530 Subject: [PATCH 13/52] chore: fix conflicts (cherry picked from commit d04965b6b25e4f6079ca37270948a1639e69b1eb) --- erpnext/manufacturing/doctype/workstation/workstation.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 3a60af341d9..2b08930a4eb 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -276,13 +276,9 @@ def get_raw_materials(job_cards): "Job Card Item", ======= @frappe.whitelist() -<<<<<<< HEAD -def get_raw_materials(job_card): -======= def get_raw_materials(job_card: str): frappe.has_permission("Job Card", "read", doc=job_card, throw=True) ->>>>>>> cf127e8900 (fix: permissions in workstation file) raw_materials = frappe.get_all( "Job Card", >>>>>>> 0fea93388d (fix: permissions in workstation file) @@ -488,13 +484,9 @@ def update_job_card(job_card: str, method: str, **kwargs): @frappe.whitelist() -<<<<<<< HEAD -def validate_job_card(job_card, status): -======= def validate_job_card(job_card: str, status: str): frappe.has_permission("Job Card", "read", doc=job_card, throw=True) ->>>>>>> cf127e8900 (fix: permissions in workstation file) job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) current_status = job_card_details.status From f4630273ad829462f488656710ebcaae420907ce Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 12 Jun 2026 17:17:03 +0530 Subject: [PATCH 14/52] chore: fix linters issue (cherry picked from commit 11c7a35eaeda7e110817e4eeb71796a8fbe61145) --- erpnext/manufacturing/doctype/workstation/workstation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 2b08930a4eb..b384dfa1837 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -19,6 +19,7 @@ from frappe.utils import ( time_diff_in_seconds, to_timedelta, ) +from frappe.utils.data import DateTimeLikeObject from erpnext.support.doctype.issue.issue import get_holidays @@ -176,7 +177,7 @@ class Workstation(Document): return schedule_date @frappe.whitelist() - def start_job(self, job_card, from_time, employee): + def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str): doc = frappe.get_doc("Job Card", job_card) doc.check_permission("write") @@ -186,7 +187,7 @@ class Workstation(Document): return doc @frappe.whitelist() - def complete_job(self, job_card, qty, to_time): + def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject): doc = frappe.get_doc("Job Card", job_card) doc.check_permission("submit") From baafb95e74c44f8f38c8fa6d244d1ca93e322dab Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 12 Jun 2026 18:36:53 +0530 Subject: [PATCH 15/52] chore: fix conflicts --- .../doctype/workstation/workstation.py | 89 +------------------ 1 file changed, 1 insertion(+), 88 deletions(-) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index b384dfa1837..216194f68ff 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -66,13 +66,7 @@ class Workstation(Document): # end: auto-generated types def before_save(self): -<<<<<<< HEAD - self.set_data_based_on_workstation_type() -======= - if self.has_value_changed("workstation_type"): - self._set_data_based_on_workstation_type() - ->>>>>>> 0fea93388d (fix: permissions in workstation file) + self._set_data_based_on_workstation_type() self.set_hour_rate() self.set_total_working_hours() @@ -269,20 +263,11 @@ def get_status_color(status): return color_map.get(status, "var(--bg-blue)") -<<<<<<< HEAD def get_raw_materials(job_cards): raw_materials = {} data = frappe.get_all( "Job Card Item", -======= -@frappe.whitelist() -def get_raw_materials(job_card: str): - frappe.has_permission("Job Card", "read", doc=job_card, throw=True) - - raw_materials = frappe.get_all( - "Job Card", ->>>>>>> 0fea93388d (fix: permissions in workstation file) fields=[ "parent", "item_code", @@ -435,75 +420,3 @@ def get_workstations(**kwargs): d.status_image = d.off_status_image return data -<<<<<<< HEAD -======= - - -def get_color_map(): - return { - "Production": "green", - "Off": "gray", - "Idle": "gray", - "Problem": "red", - "Maintenance": "yellow", - "Setup": "blue", - } - - -ALLOWED_JOB_CARD_METHODS = frozenset( - { - "start_timer", - "pause_job", - "resume_job", - "complete_job_card", - } -) - - -@frappe.whitelist() -def update_job_card(job_card: str, method: str, **kwargs): - if method not in ALLOWED_JOB_CARD_METHODS: - frappe.throw( - _("Method {0} is not allowed to be run on a Job Card.").format(bold(method)), - frappe.PermissionError, - title=_("Not Allowed"), - ) - - doc = frappe.get_doc("Job Card", job_card) - doc.check_permission("write") - - if isinstance(kwargs, dict): - kwargs = frappe._dict(kwargs) - - if kwargs.get("employees"): - kwargs.employees = frappe.parse_json(kwargs.employees) - - if kwargs.qty and isinstance(kwargs.qty, str): - kwargs.qty = flt(kwargs.qty) - - doc.run_method(method, **kwargs) - - -@frappe.whitelist() -def validate_job_card(job_card: str, status: str): - frappe.has_permission("Job Card", "read", doc=job_card, throw=True) - - job_card_details = frappe.db.get_value("Job Card", job_card, ["status", "for_quantity"], as_dict=1) - - current_status = job_card_details.status - if current_status != status: - if status == "Open": - frappe.throw( - _("The job card {0} is in {1} state and you cannot start it again.").format( - job_card, current_status - ) - ) - else: - frappe.throw( - _("The job card {0} is in {1} state and you cannot complete.").format( - job_card, current_status - ) - ) - - return job_card_details.for_quantity ->>>>>>> 0fea93388d (fix: permissions in workstation file) From f4e6f143421ce92639668c70d2ac1d1aaa500d9e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 11 Jun 2026 18:00:42 +0530 Subject: [PATCH 16/52] fix: multiple issues related to BOM Creator --- .../doctype/bom_creator/bom_creator.py | 75 ++++++++---- .../doctype/bom_creator/test_bom_creator.py | 109 ++++++++++++++++++ .../bom_configurator.bundle.js | 6 +- 3 files changed, 165 insertions(+), 25 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 55d37b3e588..54c402870a9 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -152,6 +152,7 @@ class BOMCreator(Document): @frappe.whitelist() def add_boms(self): + frappe.has_permission("BOM Creator", "submit", doc=self, throw=True) self.submit() def set_rate_for_items(self): @@ -209,10 +210,14 @@ class BOMCreator(Document): frappe.throw(_("Please set {0} in BOM Creator {1}").format(_(label), self.name)) def on_submit(self): - self.enqueue_create_boms() + self.enqueue_bom_creation() @frappe.whitelist() def enqueue_create_boms(self): + frappe.has_permission("BOM Creator", "submit", doc=self, throw=True) + self.enqueue_bom_creation() + + def enqueue_bom_creation(self): frappe.enqueue( self.create_boms, queue="short", @@ -281,6 +286,23 @@ class BOMCreator(Document): frappe.msgprint(_("BOMs creation failed")) + @frappe.whitelist() + def edit_qty(self, docname: str, qty: float): + frappe.has_permission("BOM Creator", "write", doc=self, throw=True) + + if not frappe.db.exists("BOM Creator Item", {"name": docname, "parent": self.name}): + frappe.throw(_("BOM Creator Item {0} does not exist").format(docname)) + + for row in self.items: + if row.name == docname: + row.qty = flt(qty) + break + + self.set_rate_for_items() + self.save() + + return self + def create_bom(self, row, production_item_wise_rm): bom_creator_item = row.name if row.name != self.name else "" if frappe.db.exists( @@ -336,18 +358,23 @@ class BOMCreator(Document): production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name @frappe.whitelist() - def get_default_bom(self, item_code) -> str: + def get_default_bom(self, item_code: str) -> str: + frappe.has_permission("BOM Creator", "read", doc=self, throw=True) return frappe.get_cached_value("Item", item_code, "default_bom") @frappe.whitelist() -def get_children(doctype=None, parent=None, **kwargs): +def get_children(doctype: str | None = None, parent: str | None = None, **kwargs): + # by default get_children takes first parameter as doctype, so added in the function + if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + frappe.has_permission("BOM Creator", "read", doc=kwargs.parent_id, throw=True) + fields = [ "item_code as value", "item_name as title", @@ -381,6 +408,8 @@ def add_item(**kwargs): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) + doc = frappe.get_doc("BOM Creator", kwargs.parent) item_info = get_item_details(kwargs.item_code) @@ -413,6 +442,8 @@ def add_sub_assembly(**kwargs): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) + frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) + doc = frappe.get_doc("BOM Creator", kwargs.parent) bom_item = frappe.parse_json(kwargs.bom_item) @@ -496,27 +527,29 @@ def delete_node(**kwargs): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) - items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent) + frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) + + updated = False if kwargs.docname: + if not frappe.db.exists("BOM Creator Item", {"name": kwargs.docname, "parent": kwargs.parent}): + frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname)) + frappe.delete_doc("BOM Creator Item", kwargs.docname) + updated = True - for item in items: - frappe.delete_doc("BOM Creator Item", item.name) - if item.expandable: - delete_node(fg_item=item.value, parent=item.parent_id) + items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent) + if items: + for item in items: + updated = True + frappe.delete_doc("BOM Creator Item", item.name) + if item.expandable: + delete_node(fg_item=item.value, parent=item.parent_id) - doc = frappe.get_doc("BOM Creator", kwargs.parent) - doc.set_rate_for_items() - doc.save() + if updated: + doc = frappe.get_doc("BOM Creator", kwargs.parent) + doc.set_rate_for_items() + doc.save() - return doc + return doc - -@frappe.whitelist() -def edit_qty(doctype, docname, qty, parent): - frappe.db.set_value(doctype, docname, "qty", qty) - doc = frappe.get_doc("BOM Creator", parent) - doc.set_rate_for_items() - doc.save() - - return doc + return {} diff --git a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py index a835de78274..6b5c28cd7bc 100644 --- a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py @@ -9,6 +9,8 @@ from frappe.tests.utils import FrappeTestCase from erpnext.manufacturing.doctype.bom_creator.bom_creator import ( add_item, add_sub_assembly, + delete_node, + edit_qty, ) from erpnext.stock.doctype.item.test_item import make_item @@ -245,6 +247,113 @@ class TestBOMCreator(FrappeTestCase): data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1}) self.assertEqual(len(data), 2) + def test_edit_qty_on_item_row(self): + doc = make_bom_creator_with_item("Bicycle Edit Qty", "Pedal Assembly", qty=2) + row_name = doc.items[0].name + + edit_qty(docname=row_name, qty=5, parent=doc.name) + + doc.reload() + self.assertEqual(doc.items[0].qty, 5.0) + + def test_edit_qty_rejects_foreign_item(self): + # A BOM Creator Item belonging to a different BOM Creator must not be + # editable through a BOM Creator the user owns. + doc = make_bom_creator_with_item("Bicycle Owner BOM", "Pedal Assembly", qty=1) + other = make_bom_creator_with_item("Bicycle Foreign BOM", "Frame Assembly", qty=1) + foreign_row = other.items[0].name + + self.assertRaises(frappe.ValidationError, edit_qty, docname=foreign_row, qty=5, parent=doc.name) + + # The foreign row must be left untouched. + self.assertEqual(frappe.db.get_value("BOM Creator Item", foreign_row, "qty"), 1.0) + + def test_delete_node_removes_item(self): + doc = make_bom_creator_with_item("Bicycle Delete Node", "Pedal Assembly", qty=1) + row_name = doc.items[0].name + + delete_node(parent=doc.name, fg_item="Bicycle Delete Node Item", docname=row_name) + + self.assertFalse(frappe.db.exists("BOM Creator Item", row_name)) + + def test_delete_node_rejects_foreign_item(self): + doc = make_bom_creator_with_item("Bicycle Delete Owner", "Pedal Assembly", qty=1) + other = make_bom_creator_with_item("Bicycle Delete Foreign", "Frame Assembly", qty=1) + foreign_row = other.items[0].name + + self.assertRaises( + frappe.ValidationError, + delete_node, + parent=doc.name, + fg_item="Bicycle Delete Owner Item", + docname=foreign_row, + ) + + # The foreign row must still exist. + self.assertTrue(frappe.db.exists("BOM Creator Item", foreign_row)) + + def test_whitelisted_methods_require_write_permission(self): + doc = make_bom_creator_with_item("Bicycle Perm Check", "Pedal Assembly", qty=1) + row_name = doc.items[0].name + + user = create_user_without_bom_access() + frappe.set_user(user) + try: + self.assertRaises(frappe.PermissionError, edit_qty, docname=row_name, qty=3, parent=doc.name) + self.assertRaises( + frappe.PermissionError, + delete_node, + parent=doc.name, + fg_item="Bicycle Perm Check Item", + docname=row_name, + ) + finally: + frappe.set_user("Administrator") + + +def make_bom_creator_with_item(name, item_code, qty=1): + final_product = f"{name} Item" + make_item(final_product, {"item_group": "Raw Material", "stock_uom": "Nos"}) + + doc = make_bom_creator( + name=name, + company="_Test Company", + item_code=final_product, + qty=1, + rm_cosy_as_per="Valuation Rate", + currency="INR", + plc_conversion_rate=1, + conversion_rate=1, + ) + + add_item( + parent=doc.name, + fg_item=final_product, + fg_reference_id=doc.name, + item_code=item_code, + qty=qty, + ) + + doc.reload() + return doc + + +def create_user_without_bom_access(): + user = "bom_creator_no_access@example.com" + if not frappe.db.exists("User", user): + frappe.get_doc( + { + "doctype": "User", + "email": user, + "first_name": "BOM No Access", + "send_welcome_email": 0, + # Stock User has no read/write permission on BOM Creator. + "roles": [{"role": "Stock User"}], + } + ).insert(ignore_permissions=True) + + return user + def create_items(): raw_materials = [ diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js index d25ca212b41..9d79d2f019e 100644 --- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js +++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js @@ -408,16 +408,14 @@ class BOMConfigurator { frappe.prompt( [{ label: __("Qty"), fieldname: "qty", default: qty, fieldtype: "Float", reqd: 1 }], (data) => { - let doctype = node.data.doctype || this.frm.doc.doctype; let docname = node.data.name || this.frm.doc.name; frappe.call({ - method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.edit_qty", + method: "edit_qty", + doc: this.frm.doc, args: { - doctype: doctype, docname: docname, qty: data.qty, - parent: node.data.parent_id ? node.data.parent_id : this.frm.doc.name, }, callback: (r) => { node.data.qty = data.qty; From 7c78aa6e5d5ddbd4fd98bab4c741fc30ba556638 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 12 Jun 2026 12:44:37 +0530 Subject: [PATCH 17/52] fix: converted whitelist non class methods to class methods --- .../doctype/bom_creator/bom_creator.py | 284 +++++++++--------- .../doctype/bom_creator/test_bom_creator.py | 131 +------- .../bom_configurator.bundle.js | 25 +- 3 files changed, 158 insertions(+), 282 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 54c402870a9..71ba95cc8a6 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -152,7 +152,7 @@ class BOMCreator(Document): @frappe.whitelist() def add_boms(self): - frappe.has_permission("BOM Creator", "submit", doc=self, throw=True) + self.check_permission("submit") self.submit() def set_rate_for_items(self): @@ -214,7 +214,7 @@ class BOMCreator(Document): @frappe.whitelist() def enqueue_create_boms(self): - frappe.has_permission("BOM Creator", "submit", doc=self, throw=True) + self.check_permission("submit") self.enqueue_bom_creation() def enqueue_bom_creation(self): @@ -288,7 +288,7 @@ class BOMCreator(Document): @frappe.whitelist() def edit_qty(self, docname: str, qty: float): - frappe.has_permission("BOM Creator", "write", doc=self, throw=True) + self.check_permission("write") if not frappe.db.exists("BOM Creator Item", {"name": docname, "parent": self.name}): frappe.throw(_("BOM Creator Item {0} does not exist").format(docname)) @@ -359,9 +359,149 @@ class BOMCreator(Document): @frappe.whitelist() def get_default_bom(self, item_code: str) -> str: - frappe.has_permission("BOM Creator", "read", doc=self, throw=True) + self.check_permission("read") return frappe.get_cached_value("Item", item_code, "default_bom") + @frappe.whitelist() + def add_item(self, **kwargs): + self.check_permission("write") + + if isinstance(kwargs, str): + kwargs = frappe.parse_json(kwargs) + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + item_info = get_item_details(kwargs.item_code) + + parent_row_no = "" + if kwargs.fg_reference_id and self.name != kwargs.fg_reference_id: + parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id) + + kwargs.update( + { + "uom": item_info.stock_uom, + "stock_uom": item_info.stock_uom, + "conversion_factor": 1, + } + ) + + if parent_row_no: + kwargs.update({"parent_row_no": parent_row_no}) + + for key in BOM_ITEM_FIELDS: + if key not in kwargs: + kwargs[key] = "" + + self.append("items", kwargs) + self.save() + + return self + + @frappe.whitelist() + def add_sub_assembly(self, **kwargs): + self.check_permission("write") + + if isinstance(kwargs, str): + kwargs = frappe.parse_json(kwargs) + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + bom_item = frappe.parse_json(kwargs.bom_item) + + name = kwargs.fg_reference_id + parent_row_no = "" + if not kwargs.convert_to_sub_assembly: + item_info = get_item_details(bom_item.item_code) + parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id) + + item_row = self.append( + "items", + { + "item_code": bom_item.item_code, + "qty": bom_item.qty, + "uom": item_info.stock_uom, + "fg_item": kwargs.fg_item, + "conversion_factor": 1, + "parent_row_no": parent_row_no, + "fg_reference_id": name, + "stock_qty": bom_item.qty, + "do_not_explode": 1, + "is_expandable": 1, + "stock_uom": item_info.stock_uom, + "allow_alternative_item": kwargs.allow_alternative_item, + }, + ) + + parent_row_no = item_row.idx + name = "" + else: + parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id) + + for row in bom_item.get("items"): + row = frappe._dict(row) + item_info = get_item_details(row.item_code) + self.append( + "items", + { + "item_code": row.item_code, + "qty": row.qty, + "fg_item": bom_item.item_code, + "uom": item_info.stock_uom, + "fg_reference_id": name, + "parent_row_no": parent_row_no, + "conversion_factor": 1, + "do_not_explode": 1, + "stock_qty": row.qty, + "stock_uom": item_info.stock_uom, + }, + ) + + self.save() + + return self + + @frappe.whitelist() + def delete_node(self, **kwargs): + self.check_permission("write") + + if isinstance(kwargs, str): + kwargs = frappe.parse_json(kwargs) + + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + updated = False + if kwargs.docname: + row = next((row for row in self.items if row.name == kwargs.docname), None) + if not row: + frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname)) + + row.delete() + self.remove(row) + updated = True + + items = get_children(parent=kwargs.fg_item, parent_id=self.name) + if items: + for item in items: + updated = True + child_row = next((row for row in self.items if row.name == item.name), None) + if child_row: + child_row.delete() + self.remove(child_row) + + if item.expandable: + self.delete_node(fg_item=item.value) + + if updated: + self.set_rate_for_items() + self.save() + + return self + + return frappe._dict() + @frappe.whitelist() def get_children(doctype: str | None = None, parent: str | None = None, **kwargs): @@ -400,106 +540,6 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs return frappe.get_all("BOM Creator Item", fields=fields, filters=query_filters, order_by="idx") -@frappe.whitelist() -def add_item(**kwargs): - if isinstance(kwargs, str): - kwargs = frappe.parse_json(kwargs) - - if isinstance(kwargs, dict): - kwargs = frappe._dict(kwargs) - - frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) - - doc = frappe.get_doc("BOM Creator", kwargs.parent) - item_info = get_item_details(kwargs.item_code) - - parent_row_no = "" - if kwargs.fg_reference_id and doc.name != kwargs.fg_reference_id: - parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) - - kwargs.update( - { - "uom": item_info.stock_uom, - "stock_uom": item_info.stock_uom, - "conversion_factor": 1, - } - ) - - if parent_row_no: - kwargs.update({"parent_row_no": parent_row_no}) - - doc.append("items", kwargs) - doc.save() - - return doc - - -@frappe.whitelist() -def add_sub_assembly(**kwargs): - if isinstance(kwargs, str): - kwargs = frappe.parse_json(kwargs) - - if isinstance(kwargs, dict): - kwargs = frappe._dict(kwargs) - - frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) - - doc = frappe.get_doc("BOM Creator", kwargs.parent) - bom_item = frappe.parse_json(kwargs.bom_item) - - name = kwargs.fg_reference_id - parent_row_no = "" - if not kwargs.convert_to_sub_assembly: - item_info = get_item_details(bom_item.item_code) - parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) - - item_row = doc.append( - "items", - { - "item_code": bom_item.item_code, - "qty": bom_item.qty, - "uom": item_info.stock_uom, - "fg_item": kwargs.fg_item, - "conversion_factor": 1, - "parent_row_no": parent_row_no, - "fg_reference_id": name, - "stock_qty": bom_item.qty, - "do_not_explode": 1, - "is_expandable": 1, - "stock_uom": item_info.stock_uom, - "allow_alternative_item": kwargs.allow_alternative_item, - }, - ) - - parent_row_no = item_row.idx - name = "" - else: - parent_row_no = get_parent_row_no(doc, kwargs.fg_reference_id) - - for row in bom_item.get("items"): - row = frappe._dict(row) - item_info = get_item_details(row.item_code) - doc.append( - "items", - { - "item_code": row.item_code, - "qty": row.qty, - "fg_item": bom_item.item_code, - "uom": item_info.stock_uom, - "fg_reference_id": name, - "parent_row_no": parent_row_no, - "conversion_factor": 1, - "do_not_explode": 1, - "stock_qty": row.qty, - "stock_uom": item_info.stock_uom, - }, - ) - - doc.save() - - return doc - - def get_item_details(item_code): return frappe.get_cached_value( "Item", item_code, ["item_name", "description", "image", "stock_uom", "default_bom"], as_dict=1 @@ -517,39 +557,3 @@ def get_parent_row_no(doc, name): frappe.msgprint(_("Parent Row No not found for {0}").format(name), alert=True) return None - - -@frappe.whitelist() -def delete_node(**kwargs): - if isinstance(kwargs, str): - kwargs = frappe.parse_json(kwargs) - - if isinstance(kwargs, dict): - kwargs = frappe._dict(kwargs) - - frappe.has_permission("BOM Creator", "write", doc=kwargs.parent, throw=True) - - updated = False - if kwargs.docname: - if not frappe.db.exists("BOM Creator Item", {"name": kwargs.docname, "parent": kwargs.parent}): - frappe.throw(_("BOM Creator Item with name {0} does not exist").format(kwargs.docname)) - - frappe.delete_doc("BOM Creator Item", kwargs.docname) - updated = True - - items = get_children(parent=kwargs.fg_item, parent_id=kwargs.parent) - if items: - for item in items: - updated = True - frappe.delete_doc("BOM Creator Item", item.name) - if item.expandable: - delete_node(fg_item=item.value, parent=item.parent_id) - - if updated: - doc = frappe.get_doc("BOM Creator", kwargs.parent) - doc.set_rate_for_items() - doc.save() - - return doc - - return {} diff --git a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py index 6b5c28cd7bc..331fdf61768 100644 --- a/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/test_bom_creator.py @@ -6,12 +6,6 @@ import random import frappe from frappe.tests.utils import FrappeTestCase -from erpnext.manufacturing.doctype.bom_creator.bom_creator import ( - add_item, - add_sub_assembly, - delete_node, - edit_qty, -) from erpnext.stock.doctype.item.test_item import make_item @@ -40,8 +34,7 @@ class TestBOMCreator(FrappeTestCase): conversion_rate=1, ) - add_sub_assembly( - parent=doc.name, + doc.add_sub_assembly( fg_item=final_product, fg_reference_id=doc.name, bom_item={ @@ -93,8 +86,7 @@ class TestBOMCreator(FrappeTestCase): conversion_rate=1, ) - add_item( - parent=doc.name, + doc.add_item( fg_item=final_product, fg_reference_id=doc.name, item_code="Pedal Assembly", @@ -135,8 +127,7 @@ class TestBOMCreator(FrappeTestCase): conversion_rate=1, ) - add_item( - parent=doc.name, + doc.add_item( fg_item=final_product, fg_reference_id=doc.name, item_code="Pedal Assembly", @@ -146,9 +137,8 @@ class TestBOMCreator(FrappeTestCase): doc.reload() self.assertEqual(doc.items[0].is_expandable, 0) - add_sub_assembly( + doc.add_sub_assembly( convert_to_sub_assembly=1, - parent=doc.name, fg_item=final_product, fg_reference_id=doc.items[0].name, bom_item={ @@ -201,8 +191,7 @@ class TestBOMCreator(FrappeTestCase): conversion_rate=1, ) - add_item( - parent=doc.name, + doc.add_item( fg_item=final_product, fg_reference_id=doc.name, item_code="Pedal Assembly", @@ -212,9 +201,8 @@ class TestBOMCreator(FrappeTestCase): doc.reload() self.assertEqual(doc.items[0].is_expandable, 0) - add_sub_assembly( + doc.add_sub_assembly( convert_to_sub_assembly=1, - parent=doc.name, fg_item=final_product, fg_reference_id=doc.items[0].name, bom_item={ @@ -247,113 +235,6 @@ class TestBOMCreator(FrappeTestCase): data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1}) self.assertEqual(len(data), 2) - def test_edit_qty_on_item_row(self): - doc = make_bom_creator_with_item("Bicycle Edit Qty", "Pedal Assembly", qty=2) - row_name = doc.items[0].name - - edit_qty(docname=row_name, qty=5, parent=doc.name) - - doc.reload() - self.assertEqual(doc.items[0].qty, 5.0) - - def test_edit_qty_rejects_foreign_item(self): - # A BOM Creator Item belonging to a different BOM Creator must not be - # editable through a BOM Creator the user owns. - doc = make_bom_creator_with_item("Bicycle Owner BOM", "Pedal Assembly", qty=1) - other = make_bom_creator_with_item("Bicycle Foreign BOM", "Frame Assembly", qty=1) - foreign_row = other.items[0].name - - self.assertRaises(frappe.ValidationError, edit_qty, docname=foreign_row, qty=5, parent=doc.name) - - # The foreign row must be left untouched. - self.assertEqual(frappe.db.get_value("BOM Creator Item", foreign_row, "qty"), 1.0) - - def test_delete_node_removes_item(self): - doc = make_bom_creator_with_item("Bicycle Delete Node", "Pedal Assembly", qty=1) - row_name = doc.items[0].name - - delete_node(parent=doc.name, fg_item="Bicycle Delete Node Item", docname=row_name) - - self.assertFalse(frappe.db.exists("BOM Creator Item", row_name)) - - def test_delete_node_rejects_foreign_item(self): - doc = make_bom_creator_with_item("Bicycle Delete Owner", "Pedal Assembly", qty=1) - other = make_bom_creator_with_item("Bicycle Delete Foreign", "Frame Assembly", qty=1) - foreign_row = other.items[0].name - - self.assertRaises( - frappe.ValidationError, - delete_node, - parent=doc.name, - fg_item="Bicycle Delete Owner Item", - docname=foreign_row, - ) - - # The foreign row must still exist. - self.assertTrue(frappe.db.exists("BOM Creator Item", foreign_row)) - - def test_whitelisted_methods_require_write_permission(self): - doc = make_bom_creator_with_item("Bicycle Perm Check", "Pedal Assembly", qty=1) - row_name = doc.items[0].name - - user = create_user_without_bom_access() - frappe.set_user(user) - try: - self.assertRaises(frappe.PermissionError, edit_qty, docname=row_name, qty=3, parent=doc.name) - self.assertRaises( - frappe.PermissionError, - delete_node, - parent=doc.name, - fg_item="Bicycle Perm Check Item", - docname=row_name, - ) - finally: - frappe.set_user("Administrator") - - -def make_bom_creator_with_item(name, item_code, qty=1): - final_product = f"{name} Item" - make_item(final_product, {"item_group": "Raw Material", "stock_uom": "Nos"}) - - doc = make_bom_creator( - name=name, - company="_Test Company", - item_code=final_product, - qty=1, - rm_cosy_as_per="Valuation Rate", - currency="INR", - plc_conversion_rate=1, - conversion_rate=1, - ) - - add_item( - parent=doc.name, - fg_item=final_product, - fg_reference_id=doc.name, - item_code=item_code, - qty=qty, - ) - - doc.reload() - return doc - - -def create_user_without_bom_access(): - user = "bom_creator_no_access@example.com" - if not frappe.db.exists("User", user): - frappe.get_doc( - { - "doctype": "User", - "email": user, - "first_name": "BOM No Access", - "send_welcome_email": 0, - # Stock User has no read/write permission on BOM Creator. - "roles": [{"role": "Stock User"}], - } - ).insert(ignore_permissions=True) - - return user - def create_items(): raw_materials = [ diff --git a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js index 9d79d2f019e..240c86a90d9 100644 --- a/erpnext/public/js/bom_configurator/bom_configurator.bundle.js +++ b/erpnext/public/js/bom_configurator/bom_configurator.bundle.js @@ -219,14 +219,10 @@ class BOMConfigurator { }, ], (data) => { - if (!node.data.parent_id) { - node.data.parent_id = this.frm.doc.name; - } - frappe.call({ - method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_item", + method: "add_item", + doc: this.frm.doc, args: { - parent: node.data.parent_id, fg_item: node.data.value, item_code: data.item_code, fg_reference_id: node.data.name || this.frm.doc.name, @@ -255,14 +251,10 @@ class BOMConfigurator { dialog.set_primary_action(__("Add"), () => { let bom_item = dialog.get_values(); - if (!node.data?.parent_id) { - node.data.parent_id = this.frm.doc.name; - } - frappe.call({ - method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly", + method: "add_sub_assembly", + doc: this.frm.doc, args: { - parent: node.data.parent_id, fg_item: node.data.value, fg_reference_id: node.data.name || this.frm.doc.name, bom_item: bom_item, @@ -357,9 +349,9 @@ class BOMConfigurator { let bom_item = dialog.get_values(); frappe.call({ - method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.add_sub_assembly", + method: "add_sub_assembly", + doc: this.frm.doc, args: { - parent: node.data.parent_id, fg_item: node.data.value, bom_item: bom_item, fg_reference_id: node.data.name || this.frm.doc.name, @@ -389,11 +381,10 @@ class BOMConfigurator { delete_node(node, view) { frappe.confirm(__("Are you sure you want to delete this Item?"), () => { frappe.call({ - method: "erpnext.manufacturing.doctype.bom_creator.bom_creator.delete_node", + method: "delete_node", + doc: this.frm.doc, args: { - parent: node.data.parent_id, fg_item: node.data.value, - doctype: node.data.doctype, docname: node.data.name, }, callback: (r) => { From 209977f6a30551daa1dfaedbc12deae49c9551a3 Mon Sep 17 00:00:00 2001 From: SandraFrappe Date: Fri, 12 Jun 2026 14:44:22 +0530 Subject: [PATCH 18/52] fix: pass source cost center to target cost center (cherry picked from commit 9ea766fc107fd294251dc00f390b6e833a8ab0f0) --- erpnext/controllers/sales_and_purchase_return.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 15e595a3ca1..ef5feafb45c 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -533,6 +533,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai target_doc.so_detail = source_doc.so_detail target_doc.expense_account = source_doc.expense_account target_doc.dn_detail = source_doc.name + target_doc.cost_center = source_doc.cost_center if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return elif doctype == "Sales Invoice" or doctype == "POS Invoice": From d4605771dae7d9171052cecf271bd3c716bfc93b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 09:08:30 +0000 Subject: [PATCH 19/52] fix: opportunity creation from contact us page (backport #55841) (#55866) * fix: opportunity creation from contact us page (#55841) (cherry picked from commit c933e34914e9ef0716451d4dc456e45d69364ff1) # Conflicts: # erpnext/crm/doctype/crm_settings/crm_settings.json # erpnext/templates/utils.py * chore: resolve conflict (crm_settings.json) * chore: resolve conflicts (send_message) --------- Co-authored-by: Diptanil Saha --- erpnext/crm/doctype/crm_settings/crm_settings.json | 11 +++++++++-- erpnext/crm/doctype/crm_settings/crm_settings.py | 13 +++++++++++++ erpnext/crm/utils.py | 5 +++++ erpnext/hooks.py | 3 +++ erpnext/templates/utils.py | 12 +++++++++++- 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index f9dc3902c8f..28d43c97d29 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -14,6 +14,7 @@ "opportunity_section", "close_opportunity_after_days", "column_break_9", + "enable_opportunity_creation_from_contact_us", "quotation_section", "default_valid_till", "section_break_13", @@ -98,13 +99,19 @@ "fieldname": "update_timestamp_on_new_communication", "fieldtype": "Check", "label": "Update timestamp on new communication" + }, + { + "default": "0", + "fieldname": "enable_opportunity_creation_from_contact_us", + "fieldtype": "Check", + "label": "Enable Opportunity Creation from Contact Us" } ], "icon": "fa fa-cog", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-16 16:12:14.889455", + "modified": "2026-06-11 23:09:49.750381", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", @@ -144,4 +151,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 23992043145..01cdaf41bde 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -2,6 +2,7 @@ # For license information, please see license.txt import frappe +from frappe import _ from frappe.model.document import Document @@ -20,8 +21,20 @@ class CRMSettings(Document): carry_forward_communication_and_comments: DF.Check close_opportunity_after_days: DF.Int default_valid_till: DF.Data | None + enable_opportunity_creation_from_contact_us: DF.Check update_timestamp_on_new_communication: DF.Check # end: auto-generated types def validate(self): frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", "")) + self.validate_enable_opportunity_creation_from_contact_us() + + def validate_enable_opportunity_creation_from_contact_us(self): + contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled") + + if self.enable_opportunity_creation_from_contact_us and contact_disabled: + frappe.throw( + _( + "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled." + ) + ) diff --git a/erpnext/crm/utils.py b/erpnext/crm/utils.py index eb784c28ca7..8e6574bde4d 100644 --- a/erpnext/crm/utils.py +++ b/erpnext/crm/utils.py @@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today from pypika import functions +def disable_opportunity_creation_on_contact_us_disabled(doc, method): + if doc.is_disabled: + frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0) + + def update_lead_phone_numbers(contact, method): if contact.phone_nos: contact_lead = contact.get_link_for("Lead") diff --git a/erpnext/hooks.py b/erpnext/hooks.py index f3ccc5783a7..a1c64b60377 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -355,6 +355,9 @@ doc_events = { "Event": { "after_insert": "erpnext.crm.utils.link_events_with_prospect", }, + "Contact Us Settings": { + "on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled", + }, "Sales Invoice": { "on_submit": [ "erpnext.regional.create_transaction_log", diff --git a/erpnext/templates/utils.py b/erpnext/templates/utils.py index 15af9f0f014..fef82b8ea9e 100644 --- a/erpnext/templates/utils.py +++ b/erpnext/templates/utils.py @@ -3,10 +3,12 @@ import frappe +from frappe.rate_limiter import rate_limit from frappe.utils import escape_html -@frappe.whitelist(allow_guest=True) +@frappe.whitelist(allow_guest=True, methods=["POST"]) +@rate_limit(limit=10, seconds=3 * 60) def send_message(sender, message, subject="Website Query"): from frappe.www.contact import send_message as website_send_message @@ -14,6 +16,14 @@ def send_message(sender, message, subject="Website Query"): message = escape_html(message) + oppotunity_creation = frappe.get_single_value( + "CRM Settings", "enable_opportunity_creation_from_contact_us" + ) + + if not oppotunity_creation: + # Meant to silently fail instead of throwing error. + return + lead = customer = None customer = frappe.db.sql( """select distinct dl.link_name from `tabDynamic Link` dl From 559c95c8a8cb2b41b71c1a86372175fb9fd665d9 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 13 Jun 2026 17:41:48 +0530 Subject: [PATCH 20/52] fix: pemission for whitelist functions (cherry picked from commit dd56e805126be2691df457c3fa53721d49995673) # Conflicts: # erpnext/accounts/doctype/bank_clearance/bank_clearance.py # erpnext/manufacturing/doctype/bom_creator/bom_creator.py # erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py --- .../doctype/bank_clearance/bank_clearance.py | 20 + .../repost_accounting_ledger.py | 3 +- erpnext/accounts/party.py | 3 +- erpnext/crm/doctype/lead/lead.py | 3 +- .../crm/doctype/opportunity/opportunity.py | 6 +- .../doctype/bom_creator/bom_creator.py | 51 ++ .../doctype/workstation/workstation.py | 4 +- .../transaction_deletion_record.py | 2 + .../doctype/delivery_trip/delivery_trip.py | 8 +- erpnext/stock/doctype/shipment/shipment.py | 4 +- .../incorrect_serial_and_batch_bundle.py | 5 +- .../subcontracting_inward_order.py | 559 ++++++++++++++++++ .../subcontracting_order.py | 4 +- erpnext/support/doctype/issue/issue.py | 8 +- 14 files changed, 664 insertions(+), 16 deletions(-) create mode 100644 erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 36cbb321518..93243ce07b1 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -90,7 +90,27 @@ class BankClearance(Document): @frappe.whitelist() def update_clearance_date(self): +<<<<<<< HEAD clearance_date_updated = False +======= + self.check_permission("write") + invalid_document = [] + invalid_cheque_date = [] + entries_to_update = [] + + def validate_entry(d): + is_valid = True + if not d.payment_document: + invalid_document.append(str(d.idx)) + is_valid = False + + if d.clearance_date and d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date): + invalid_cheque_date.append(str(d.idx)) + is_valid = False + + return is_valid + +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) for d in self.get("payment_entries"): if d.clearance_date: if not d.payment_document: diff --git a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py index a8e468415c1..f11aeda4383 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.py @@ -154,12 +154,13 @@ class RepostAccountingLedger(Document): @frappe.whitelist() -def start_repost(account_repost_doc=str) -> None: +def start_repost(account_repost_doc: str | None = None) -> None: from erpnext.accounts.general_ledger import make_reverse_gl_entries frappe.flags.through_repost_accounting_ledger = True if account_repost_doc: repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc) + repost_doc.check_permission("write") if repost_doc.docstatus == 1: # Prevent repost on invoices with deferred accounting diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 5c0860b4fd3..14146a7872e 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -511,7 +511,8 @@ def get_party_advance_account(party_type, party, company): @frappe.whitelist() -def get_party_bank_account(party_type, party): +def get_party_bank_account(party_type: str, party: str): + frappe.has_permission("Bank Account", "read", throw=True) return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1}) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index f0f492191fb..0f901b5eff6 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -471,7 +471,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None): @frappe.whitelist() -def make_lead_from_communication(communication, ignore_communication_links=False): +def make_lead_from_communication(communication: str, ignore_communication_links: bool = False): """raise a issue from email""" doc = frappe.get_doc("Communication", communication) @@ -490,7 +490,6 @@ def make_lead_from_communication(communication, ignore_communication_links=False } ) lead.flags.ignore_mandatory = True - lead.flags.ignore_permissions = True lead.insert() lead_name = lead.name diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 91d60c924bf..b68ab28ee82 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -522,7 +522,9 @@ def auto_close_opportunity(): @frappe.whitelist() -def make_opportunity_from_communication(communication, company, ignore_communication_links=False): +def make_opportunity_from_communication( + communication: str, company: str, ignore_communication_links: bool = False +): from erpnext.crm.doctype.lead.lead import make_lead_from_communication doc = frappe.get_doc("Communication", communication) @@ -540,7 +542,7 @@ def make_opportunity_from_communication(communication, company, ignore_communica "opportunity_from": opportunity_from, "party_name": lead, } - ).insert(ignore_permissions=True) + ).insert() link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links) diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 71ba95cc8a6..3e31d972558 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -358,14 +358,53 @@ class BOMCreator(Document): production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name @frappe.whitelist() +<<<<<<< HEAD def get_default_bom(self, item_code: str) -> str: self.check_permission("read") +======= + def edit_bom_creator(self, docname: str, data: str | dict): + if not frappe.db.exists("BOM Creator Item", {"parent": self.name, "name": docname}): + frappe.throw(_("BOM Creator Item with name {0} does not exist").format(docname)) + + if isinstance(data, str): + data = frappe.parse_json(data) + + updated = False + for row in self.items: + if row.name == docname: + for key, value in data.items(): + if key in BOM_ITEM_FIELDS and row.get(key) != value: + row.set(key, value) + updated = True + break + + if updated: + self.set_rate_for_items() + self.save() + + frappe.msgprint(_("Updated successfully"), alert=True) + + return self + + def has_operations(self): + for row in self.items: + if row.operation: + return True + + return False + + @frappe.whitelist() + def get_default_bom(self, item_code) -> str: +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) return frappe.get_cached_value("Item", item_code, "default_bom") @frappe.whitelist() def add_item(self, **kwargs): +<<<<<<< HEAD self.check_permission("write") +======= +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) @@ -400,8 +439,11 @@ class BOMCreator(Document): @frappe.whitelist() def add_sub_assembly(self, **kwargs): +<<<<<<< HEAD self.check_permission("write") +======= +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) @@ -437,6 +479,12 @@ class BOMCreator(Document): parent_row_no = item_row.idx name = "" else: +<<<<<<< HEAD +======= + if sbool(kwargs.phantom): + parent_row = next(item for item in self.items if item.name == kwargs.fg_reference_id) + parent_row.is_phantom_item = 1 +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id) for row in bom_item.get("items"): @@ -464,8 +512,11 @@ class BOMCreator(Document): @frappe.whitelist() def delete_node(self, **kwargs): +<<<<<<< HEAD self.check_permission("write") +======= +>>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 216194f68ff..c45fd7852f4 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -176,7 +176,7 @@ class Workstation(Document): doc.check_permission("write") doc.append("time_logs", {"from_time": from_time, "employee": employee}) - doc.save(ignore_permissions=True) + doc.save() return doc @@ -191,7 +191,7 @@ class Workstation(Document): row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60 row.completed_qty = qty - doc.save(ignore_permissions=True) + doc.save() doc.submit() return doc diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 9fa32c9d5ec..919dd29b040 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -209,6 +209,8 @@ class TransactionDeletionRecord(Document): @frappe.whitelist() def start_deletion_tasks(self): + self.check_permission("write") + # This method is the entry point for the chain of events that follow self.db_set("status", "Running") self.enqueue_task(task="Delete Bins") diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 58f393df5d0..9e5185dc0a0 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -335,7 +335,9 @@ def get_default_address(out, name): @frappe.whitelist() -def get_contact_display(contact): +def get_contact_display(contact: str): + frappe.has_permission("Contact", "read", doc=contact, throw=True) + contact_info = frappe.db.get_value( "Contact", contact, ["first_name", "last_name", "phone", "mobile_no"], as_dict=1 ) @@ -436,7 +438,9 @@ def get_attachments(delivery_stop): @frappe.whitelist() -def get_driver_email(driver): +def get_driver_email(driver: str): + frappe.has_permission("Driver", "read", doc=driver, throw=True) + employee = frappe.db.get_value("Driver", driver, "employee") email = frappe.db.get_value("Employee", employee, "prefered_email") return {"email": email} diff --git a/erpnext/stock/doctype/shipment/shipment.py b/erpnext/stock/doctype/shipment/shipment.py index 880f6b5e1c1..e1fb199c10b 100644 --- a/erpnext/stock/doctype/shipment/shipment.py +++ b/erpnext/stock/doctype/shipment/shipment.py @@ -123,7 +123,9 @@ def get_contact_name(ref_doctype, docname): @frappe.whitelist() -def get_company_contact(user): +def get_company_contact(user: str): + frappe.has_permission("User", "read", throw=True) + contact = frappe.db.get_value( "User", user, diff --git a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py index 0b27d697a4d..2a9640bab9e 100644 --- a/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py +++ b/erpnext/stock/report/incorrect_serial_and_batch_bundle/incorrect_serial_and_batch_bundle.py @@ -134,12 +134,15 @@ def get_linked_cancelled_sabb(filters): @frappe.whitelist() -def fix_sabb_entries(selected_rows): +def fix_sabb_entries(selected_rows: str | list): + frappe.has_permission("Serial and Batch Bundle", "write", throw=True) + if isinstance(selected_rows, str): selected_rows = frappe.parse_json(selected_rows) for row in selected_rows: doc = frappe.get_doc("Serial and Batch Bundle", row.get("name")) + doc.check_permission("write") if doc.is_cancelled == 0 and not frappe.db.get_value( "Stock Ledger Entry", {"serial_and_batch_bundle": doc.name, "is_cancelled": 0}, diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py new file mode 100644 index 00000000000..79f2ed33ed2 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -0,0 +1,559 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.mapper import get_mapped_doc +from frappe.utils import comma_and, flt, get_link_to_form + +from erpnext.buying.utils import check_on_hold_or_closed_status +from erpnext.controllers.subcontracting_controller import SubcontractingController + + +class SubcontractingInwardOrder(SubcontractingController): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + from erpnext.subcontracting.doctype.subcontracting_inward_order_item.subcontracting_inward_order_item import ( + SubcontractingInwardOrderItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import ( + SubcontractingInwardOrderReceivedItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_secondary_item.subcontracting_inward_order_secondary_item import ( + SubcontractingInwardOrderSecondaryItem, + ) + from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import ( + SubcontractingInwardOrderServiceItem, + ) + + amended_from: DF.Link | None + company: DF.Link + currency: DF.Link | None + customer: DF.Link + customer_name: DF.Data + customer_warehouse: DF.Link + items: DF.Table[SubcontractingInwardOrderItem] + naming_series: DF.Literal["SCI-ORD-.YYYY.-"] + per_delivered: DF.Percent + per_process_loss: DF.Percent + per_produced: DF.Percent + per_raw_material_received: DF.Percent + per_raw_material_returned: DF.Percent + per_returned: DF.Percent + received_items: DF.Table[SubcontractingInwardOrderReceivedItem] + sales_order: DF.Link + secondary_items: DF.Table[SubcontractingInwardOrderSecondaryItem] + service_items: DF.Table[SubcontractingInwardOrderServiceItem] + set_delivery_warehouse: DF.Link | None + status: DF.Literal[ + "Draft", "Open", "Ongoing", "Produced", "Delivered", "Returned", "Cancelled", "Closed" + ] + title: DF.Data | None + transaction_date: DF.Date + # end: auto-generated types + + pass + + def validate(self): + super().validate() + self.set_is_customer_provided_item() + self.validate_customer_provided_items() + self.validate_customer_warehouse() + self.validate_service_items() + self.set_missing_values() + + def on_submit(self): + self.update_status() + self.update_subcontracted_quantity_in_so() + + def on_cancel(self): + self.update_status() + self.update_subcontracted_quantity_in_so() + + def update_status(self, status=None, update_modified=True): + if self.status == "Closed" and self.status != status: + check_on_hold_or_closed_status("Sales Order", self.sales_order) + + total_to_be_received = total_received = total_rm_returned = 0 + for rm in self.get("received_items"): + if rm.get("is_customer_provided_item"): + total_to_be_received += flt(rm.required_qty) + total_received += flt(rm.received_qty) + total_rm_returned += flt(rm.returned_qty) + + total_to_be_produced = total_produced = total_process_loss = total_delivered = total_fg_returned = 0 + for item in self.get("items"): + total_to_be_produced += flt(item.qty) + total_produced += flt(item.produced_qty) + total_process_loss += flt(item.process_loss_qty) + total_delivered += flt(item.delivered_qty) + total_fg_returned += flt(item.returned_qty) + + per_raw_material_received = flt(total_received / total_to_be_received * 100, 2) + per_raw_material_returned = flt(total_rm_returned / total_received * 100, 2) if total_received else 0 + per_produced = flt(total_produced / total_to_be_produced * 100, 2) + per_process_loss = flt(total_process_loss / total_produced * 100, 2) if total_produced else 0 + per_delivered = flt(total_delivered / total_to_be_produced * 100, 2) + per_returned = flt(total_fg_returned / total_delivered * 100, 2) if total_delivered else 0 + + self.db_set("per_raw_material_received", per_raw_material_received, update_modified=update_modified) + self.db_set("per_raw_material_returned", per_raw_material_returned, update_modified=update_modified) + self.db_set("per_produced", per_produced, update_modified=update_modified) + self.db_set("per_process_loss", per_process_loss, update_modified=update_modified) + self.db_set("per_delivered", per_delivered, update_modified=update_modified) + self.db_set("per_returned", per_returned, update_modified=update_modified) + + if self.docstatus >= 1 and not status: + if self.docstatus == 1: + if self.status == "Draft": + status = "Open" + elif self.per_returned == 100: + status = "Returned" + elif self.per_delivered == 100: + status = "Delivered" + elif self.per_produced == 100: + status = "Produced" + elif self.per_raw_material_received > 0: + status = "Ongoing" + else: + status = "Open" + elif self.docstatus == 2: + status = "Cancelled" + + if status and self.status != status: + self.db_set("status", status, update_modified=update_modified) + + def update_subcontracted_quantity_in_so(self): + for service_item in self.service_items: + doc = frappe.get_doc("Sales Order Item", service_item.sales_order_item) + doc.subcontracted_qty = ( + (doc.subcontracted_qty + service_item.qty) + if self._action == "submit" + else (doc.subcontracted_qty - service_item.qty) + ) + doc.save() + + def validate_customer_warehouse(self): + if frappe.get_cached_value("Warehouse", self.customer_warehouse, "customer") != self.customer: + frappe.throw( + _("Customer Warehouse {0} does not belong to Customer {1}.").format( + frappe.bold(self.customer_warehouse), frappe.bold(self.customer) + ) + ) + + def validate_service_items(self): + sales_order_items = [item.sales_order_item for item in self.items] + self.service_items = [ + service_item + for service_item in self.service_items + if service_item.sales_order_item in sales_order_items + ] + + for service_item in self.service_items: + item = next(item for item in self.items if item.sales_order_item == service_item.sales_order_item) + service_item.qty = item.qty * item.subcontracting_conversion_factor + service_item.fg_item_qty = item.qty + service_item.amount = service_item.qty * service_item.rate + + def populate_items_table(self): + items = [] + + for si in self.service_items: + if si.fg_item: + item = frappe.get_doc("Item", si.fg_item) + + so_item = frappe.get_doc("Sales Order Item", si.sales_order_item) + available_qty = so_item.stock_qty - so_item.subcontracted_qty + + if available_qty == 0: + continue + + si.required_qty = available_qty + conversion_factor = so_item.stock_qty / so_item.fg_item_qty + si.fg_item_qty = flt( + available_qty / conversion_factor, frappe.get_precision("Sales Order Item", "qty") + ) + si.amount = available_qty * si.rate + + bom = ( + frappe.db.get_value( + "Subcontracting BOM", + {"finished_good": item.name, "is_active": 1}, + "finished_good_bom", + ) + or item.default_bom + ) + + items.append( + { + "item_code": item.name, + "item_name": item.item_name, + "expected_delivery_date": frappe.get_cached_value( + "Sales Order Item", si.sales_order_item, "delivery_date" + ), + "description": item.description, + "qty": si.fg_item_qty, + "subcontracting_conversion_factor": conversion_factor, + "stock_uom": item.stock_uom, + "bom": bom, + "sales_order_item": si.sales_order_item, + } + ) + else: + frappe.throw( + _("Please select Finished Good Item for Service Item {0}").format( + si.item_name or si.item_code + ) + ) + + if items: + for item in items: + self.append("items", item) + + def validate_customer_provided_items(self): + """Check if atleast one raw material is customer provided""" + for item in self.get("items"): + raw_materials = [rm for rm in self.get("received_items") if rm.main_item_code == item.item_code] + if not any([rm.is_customer_provided_item for rm in raw_materials]): + frappe.throw( + _( + "Atleast one raw material for Finished Good Item {0} should be customer provided." + ).format(frappe.bold(item.item_code)) + ) + + def set_is_customer_provided_item(self): + for item in self.get("received_items"): + item.is_customer_provided_item = frappe.get_cached_value( + "Item", item.rm_item_code, "is_customer_provided_item" + ) + + @frappe.whitelist() + def make_work_order(self): + """Create Work Order from Subcontracting Inward Order.""" + wo_list = [] + + for item in self.get_production_items(): + work_order = self.create_work_order(item) + if work_order: + wo_list.append(work_order) + + self.show_list_created_message("Work Order", wo_list) + + if not wo_list: + frappe.msgprint(_("No Work Orders were created")) + + return wo_list + + def get_production_items(self): + item_list = [] + + for d in self.items: + if d.produced_qty >= d.qty: + continue + + item_details = { + "production_item": d.item_code, + "use_multi_level_bom": d.include_exploded_items, + "subcontracting_inward_order": self.name, + "bom_no": d.bom, + "stock_uom": d.stock_uom, + "company": self.company, + "project": frappe.get_cached_value("Sales Order", self.sales_order, "project"), + "source_warehouse": self.customer_warehouse, + "subcontracting_inward_order_item": d.name, + "reserve_stock": 1, + "fg_warehouse": d.delivery_warehouse, + } + + qty = min( + [ + flt( + (item.received_qty - item.returned_qty - item.work_order_qty) + / flt(item.required_qty / d.qty, d.precision("qty")), + d.precision("qty"), + ) + for item in self.get("received_items") + if item.reference_name == d.name and item.is_customer_provided_item and item.required_qty + ] + ) + qty = min( + int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty, + d.qty - d.produced_qty, + ) + + item_details.update({"qty": qty, "max_producible_qty": qty}) + item_list.append(item_details) + + return item_list + + def create_work_order(self, item): + from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError + + if flt(item.get("qty")) <= 0: + return + + wo = frappe.new_doc("Work Order") + wo.update(item) + + wo.set_work_order_operations() + wo.set_required_items() + + try: + wo.flags.ignore_mandatory = True + wo.flags.ignore_validate = True + wo.insert() + return wo.name + except OverProductionError: + pass + + def show_list_created_message(self, doctype, doc_list=None): + if not doc_list: + return + + frappe.flags.mute_messages = False + if doc_list: + doc_list = [get_link_to_form(doctype, p) for p in doc_list] + frappe.msgprint(_("{0} created").format(comma_and(doc_list))) + + @frappe.whitelist() + def make_rm_stock_entry_inward(self, target_doc=None): + def calculate_qty_as_per_bom(rm_item): + data = frappe.get_value( + "Subcontracting Inward Order Item", + {"name": rm_item.reference_name}, + ["process_loss_qty", "include_exploded_items"], + as_dict=True, + ) + stock_qty = frappe.get_value( + "BOM Explosion Item" if data.include_exploded_items else "BOM Item", + {"name": rm_item.bom_detail_no}, + "stock_qty", + ) + qty = flt( + stock_qty * data.process_loss_qty, + frappe.get_precision("Subcontracting Inward Order Received Item", "required_qty"), + ) + return rm_item.required_qty - rm_item.received_qty + rm_item.returned_qty + qty + + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Receive from Customer" + stock_entry.subcontracting_inward_order = self.name + + stock_entry.set_stock_entry_type() + + for rm_item in self.received_items: + if not rm_item.required_qty or not rm_item.is_customer_provided_item: + continue + + items_dict = { + rm_item.get("rm_item_code"): { + "scio_detail": rm_item.get("name"), + "qty": calculate_qty_as_per_bom(rm_item), + "to_warehouse": rm_item.get("warehouse"), + "stock_uom": rm_item.get("stock_uom"), + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_rm_return(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Return Raw Material to Customer" + stock_entry.set_stock_entry_type() + stock_entry.subcontracting_inward_order = self.name + + for rm_item in self.received_items: + items_dict = { + rm_item.get("rm_item_code"): { + "scio_detail": rm_item.get("name"), + "qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty, + "from_warehouse": rm_item.get("warehouse"), + "stock_uom": rm_item.get("stock_uom"), + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_subcontracting_delivery(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Subcontracting Delivery" + stock_entry.set_stock_entry_type() + stock_entry.subcontracting_inward_order = self.name + scio_details = [] + + allow_over = frappe.get_single_value("Selling Settings", "allow_delivery_of_overproduced_qty") + for fg_item in self.items: + qty = ( + fg_item.produced_qty + if allow_over + else min(fg_item.qty, fg_item.produced_qty) - fg_item.delivered_qty + ) + if qty < 0: + continue + + scio_details.append(fg_item.name) + items_dict = { + fg_item.item_code: { + "qty": qty, + "from_warehouse": fg_item.delivery_warehouse, + "stock_uom": fg_item.stock_uom, + "scio_detail": fg_item.name, + "is_finished_item": 1, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if ( + frappe.get_single_value("Selling Settings", "deliver_secondary_items") + and self.secondary_items + and scio_details + ): + secondary_items = [ + secondary_item + for secondary_item in self.secondary_items + if secondary_item.reference_name in scio_details + ] + for secondary_item in secondary_items: + qty = secondary_item.produced_qty - secondary_item.delivered_qty + if qty > 0: + items_dict = { + secondary_item.item_code: { + "qty": secondary_item.produced_qty - secondary_item.delivered_qty, + "from_warehouse": secondary_item.warehouse, + "stock_uom": secondary_item.stock_uom, + "scio_detail": secondary_item.name, + "type": secondary_item.type, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + @frappe.whitelist() + def make_subcontracting_return(self, target_doc=None): + if target_doc and target_doc.get("items"): + target_doc.items = [] + + stock_entry = get_mapped_doc( + "Subcontracting Inward Order", + self.name, + { + "Subcontracting Inward Order": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + }, + "field_map": {"name": "subcontracting_inward_order"}, + }, + }, + target_doc, + ignore_child_tables=True, + ) + + stock_entry.purpose = "Subcontracting Return" + stock_entry.set_stock_entry_type() + + for fg_item in self.items: + qty = fg_item.delivered_qty - fg_item.returned_qty + if qty < 0: + continue + + items_dict = { + fg_item.item_code: { + "qty": qty, + "stock_uom": fg_item.stock_uom, + "scio_detail": fg_item.name, + "is_finished_item": 1, + } + } + + stock_entry.add_to_stock_entry_detail(items_dict) + + if target_doc: + return stock_entry + else: + return stock_entry.as_dict() + + +@frappe.whitelist() +def update_subcontracting_inward_order_status(scio: str | Document, status: str | None = None): + if isinstance(scio, str): + scio = frappe.get_doc("Subcontracting Inward Order", scio) + + scio.check_permission("write") + scio.update_status(status) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index aaf0ec87c23..7a8d4d62124 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.utils import flt @@ -364,8 +365,9 @@ def get_mapped_subcontracting_receipt(source_name, target_doc=None): @frappe.whitelist() -def update_subcontracting_order_status(sco, status=None): +def update_subcontracting_order_status(sco: str | Document, status: str | None = None): if isinstance(sco, str): sco = frappe.get_doc("Subcontracting Order", sco) + sco.check_permission("write") sco.update_status(status) diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index faa12bd5419..c35b76cf37d 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -118,7 +118,9 @@ class Issue(Document): communication.save() @frappe.whitelist() - def split_issue(self, subject, communication_id): + def split_issue(self, subject: str, communication_id: str): + self.check_permission("write") + # Bug: Pressing enter doesn't send subject from copy import deepcopy @@ -274,7 +276,7 @@ def make_task(source_name, target_doc=None): @frappe.whitelist() -def make_issue_from_communication(communication, ignore_communication_links=False): +def make_issue_from_communication(communication: str, ignore_communication_links: bool = False): """raise a issue from email""" doc = frappe.get_doc("Communication", communication) @@ -286,7 +288,7 @@ def make_issue_from_communication(communication, ignore_communication_links=Fals "raised_by": doc.sender or "", "raised_by_phone": doc.phone_no or "", } - ).insert(ignore_permissions=True) + ).insert() link_communication_to_document(doc, "Issue", issue.name, ignore_communication_links) From 46b3e0c385658e1292f1c7c01bfd3fd356f923ad Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 13 Jun 2026 18:43:12 +0530 Subject: [PATCH 21/52] fix: clearance date update with permission checks Refactor update_clearance_date to check permissions for payment documents before processing entries. --- .../doctype/bank_clearance/bank_clearance.py | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py index 93243ce07b1..1467400b598 100644 --- a/erpnext/accounts/doctype/bank_clearance/bank_clearance.py +++ b/erpnext/accounts/doctype/bank_clearance/bank_clearance.py @@ -90,27 +90,14 @@ class BankClearance(Document): @frappe.whitelist() def update_clearance_date(self): -<<<<<<< HEAD - clearance_date_updated = False -======= - self.check_permission("write") - invalid_document = [] - invalid_cheque_date = [] - entries_to_update = [] + payment_docs = [] + for d in self.get("payment_entries"): + if d.payment_document not in payment_docs: + payment_docs.append(d.payment_document) - def validate_entry(d): - is_valid = True - if not d.payment_document: - invalid_document.append(str(d.idx)) - is_valid = False + for doctype in payment_docs: + frappe.has_permission(doctype, "write", throw=True) - if d.clearance_date and d.cheque_date and getdate(d.clearance_date) < getdate(d.cheque_date): - invalid_cheque_date.append(str(d.idx)) - is_valid = False - - return is_valid - ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) for d in self.get("payment_entries"): if d.clearance_date: if not d.payment_document: From 08d9b8275daa1763dfc0bb05e56d4929a522b43c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Sat, 13 Jun 2026 18:45:02 +0530 Subject: [PATCH 22/52] fix: BOM Creator by removing redundant permissions Removed unnecessary permission checks for whitelist functions and cleaned up the code. --- .../doctype/bom_creator/bom_creator.py | 59 -- .../subcontracting_inward_order.py | 559 ------------------ 2 files changed, 618 deletions(-) delete mode 100644 erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py index 3e31d972558..fd32d29fb50 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.py +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.py @@ -288,8 +288,6 @@ class BOMCreator(Document): @frappe.whitelist() def edit_qty(self, docname: str, qty: float): - self.check_permission("write") - if not frappe.db.exists("BOM Creator Item", {"name": docname, "parent": self.name}): frappe.throw(_("BOM Creator Item {0} does not exist").format(docname)) @@ -358,53 +356,12 @@ class BOMCreator(Document): production_item_wise_rm[(row.item_code, row.name)].bom_no = bom.name @frappe.whitelist() -<<<<<<< HEAD def get_default_bom(self, item_code: str) -> str: self.check_permission("read") -======= - def edit_bom_creator(self, docname: str, data: str | dict): - if not frappe.db.exists("BOM Creator Item", {"parent": self.name, "name": docname}): - frappe.throw(_("BOM Creator Item with name {0} does not exist").format(docname)) - - if isinstance(data, str): - data = frappe.parse_json(data) - - updated = False - for row in self.items: - if row.name == docname: - for key, value in data.items(): - if key in BOM_ITEM_FIELDS and row.get(key) != value: - row.set(key, value) - updated = True - break - - if updated: - self.set_rate_for_items() - self.save() - - frappe.msgprint(_("Updated successfully"), alert=True) - - return self - - def has_operations(self): - for row in self.items: - if row.operation: - return True - - return False - - @frappe.whitelist() - def get_default_bom(self, item_code) -> str: ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) return frappe.get_cached_value("Item", item_code, "default_bom") @frappe.whitelist() def add_item(self, **kwargs): -<<<<<<< HEAD - self.check_permission("write") - -======= ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) @@ -439,11 +396,6 @@ class BOMCreator(Document): @frappe.whitelist() def add_sub_assembly(self, **kwargs): -<<<<<<< HEAD - self.check_permission("write") - -======= ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) @@ -479,12 +431,6 @@ class BOMCreator(Document): parent_row_no = item_row.idx name = "" else: -<<<<<<< HEAD -======= - if sbool(kwargs.phantom): - parent_row = next(item for item in self.items if item.name == kwargs.fg_reference_id) - parent_row.is_phantom_item = 1 ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) parent_row_no = get_parent_row_no(self, kwargs.fg_reference_id) for row in bom_item.get("items"): @@ -512,11 +458,6 @@ class BOMCreator(Document): @frappe.whitelist() def delete_node(self, **kwargs): -<<<<<<< HEAD - self.check_permission("write") - -======= ->>>>>>> dd56e80512 (fix: pemission for whitelist functions) if isinstance(kwargs, str): kwargs = frappe.parse_json(kwargs) diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py deleted file mode 100644 index 79f2ed33ed2..00000000000 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py +++ /dev/null @@ -1,559 +0,0 @@ -# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -from frappe.utils import comma_and, flt, get_link_to_form - -from erpnext.buying.utils import check_on_hold_or_closed_status -from erpnext.controllers.subcontracting_controller import SubcontractingController - - -class SubcontractingInwardOrder(SubcontractingController): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - from erpnext.subcontracting.doctype.subcontracting_inward_order_item.subcontracting_inward_order_item import ( - SubcontractingInwardOrderItem, - ) - from erpnext.subcontracting.doctype.subcontracting_inward_order_received_item.subcontracting_inward_order_received_item import ( - SubcontractingInwardOrderReceivedItem, - ) - from erpnext.subcontracting.doctype.subcontracting_inward_order_secondary_item.subcontracting_inward_order_secondary_item import ( - SubcontractingInwardOrderSecondaryItem, - ) - from erpnext.subcontracting.doctype.subcontracting_inward_order_service_item.subcontracting_inward_order_service_item import ( - SubcontractingInwardOrderServiceItem, - ) - - amended_from: DF.Link | None - company: DF.Link - currency: DF.Link | None - customer: DF.Link - customer_name: DF.Data - customer_warehouse: DF.Link - items: DF.Table[SubcontractingInwardOrderItem] - naming_series: DF.Literal["SCI-ORD-.YYYY.-"] - per_delivered: DF.Percent - per_process_loss: DF.Percent - per_produced: DF.Percent - per_raw_material_received: DF.Percent - per_raw_material_returned: DF.Percent - per_returned: DF.Percent - received_items: DF.Table[SubcontractingInwardOrderReceivedItem] - sales_order: DF.Link - secondary_items: DF.Table[SubcontractingInwardOrderSecondaryItem] - service_items: DF.Table[SubcontractingInwardOrderServiceItem] - set_delivery_warehouse: DF.Link | None - status: DF.Literal[ - "Draft", "Open", "Ongoing", "Produced", "Delivered", "Returned", "Cancelled", "Closed" - ] - title: DF.Data | None - transaction_date: DF.Date - # end: auto-generated types - - pass - - def validate(self): - super().validate() - self.set_is_customer_provided_item() - self.validate_customer_provided_items() - self.validate_customer_warehouse() - self.validate_service_items() - self.set_missing_values() - - def on_submit(self): - self.update_status() - self.update_subcontracted_quantity_in_so() - - def on_cancel(self): - self.update_status() - self.update_subcontracted_quantity_in_so() - - def update_status(self, status=None, update_modified=True): - if self.status == "Closed" and self.status != status: - check_on_hold_or_closed_status("Sales Order", self.sales_order) - - total_to_be_received = total_received = total_rm_returned = 0 - for rm in self.get("received_items"): - if rm.get("is_customer_provided_item"): - total_to_be_received += flt(rm.required_qty) - total_received += flt(rm.received_qty) - total_rm_returned += flt(rm.returned_qty) - - total_to_be_produced = total_produced = total_process_loss = total_delivered = total_fg_returned = 0 - for item in self.get("items"): - total_to_be_produced += flt(item.qty) - total_produced += flt(item.produced_qty) - total_process_loss += flt(item.process_loss_qty) - total_delivered += flt(item.delivered_qty) - total_fg_returned += flt(item.returned_qty) - - per_raw_material_received = flt(total_received / total_to_be_received * 100, 2) - per_raw_material_returned = flt(total_rm_returned / total_received * 100, 2) if total_received else 0 - per_produced = flt(total_produced / total_to_be_produced * 100, 2) - per_process_loss = flt(total_process_loss / total_produced * 100, 2) if total_produced else 0 - per_delivered = flt(total_delivered / total_to_be_produced * 100, 2) - per_returned = flt(total_fg_returned / total_delivered * 100, 2) if total_delivered else 0 - - self.db_set("per_raw_material_received", per_raw_material_received, update_modified=update_modified) - self.db_set("per_raw_material_returned", per_raw_material_returned, update_modified=update_modified) - self.db_set("per_produced", per_produced, update_modified=update_modified) - self.db_set("per_process_loss", per_process_loss, update_modified=update_modified) - self.db_set("per_delivered", per_delivered, update_modified=update_modified) - self.db_set("per_returned", per_returned, update_modified=update_modified) - - if self.docstatus >= 1 and not status: - if self.docstatus == 1: - if self.status == "Draft": - status = "Open" - elif self.per_returned == 100: - status = "Returned" - elif self.per_delivered == 100: - status = "Delivered" - elif self.per_produced == 100: - status = "Produced" - elif self.per_raw_material_received > 0: - status = "Ongoing" - else: - status = "Open" - elif self.docstatus == 2: - status = "Cancelled" - - if status and self.status != status: - self.db_set("status", status, update_modified=update_modified) - - def update_subcontracted_quantity_in_so(self): - for service_item in self.service_items: - doc = frappe.get_doc("Sales Order Item", service_item.sales_order_item) - doc.subcontracted_qty = ( - (doc.subcontracted_qty + service_item.qty) - if self._action == "submit" - else (doc.subcontracted_qty - service_item.qty) - ) - doc.save() - - def validate_customer_warehouse(self): - if frappe.get_cached_value("Warehouse", self.customer_warehouse, "customer") != self.customer: - frappe.throw( - _("Customer Warehouse {0} does not belong to Customer {1}.").format( - frappe.bold(self.customer_warehouse), frappe.bold(self.customer) - ) - ) - - def validate_service_items(self): - sales_order_items = [item.sales_order_item for item in self.items] - self.service_items = [ - service_item - for service_item in self.service_items - if service_item.sales_order_item in sales_order_items - ] - - for service_item in self.service_items: - item = next(item for item in self.items if item.sales_order_item == service_item.sales_order_item) - service_item.qty = item.qty * item.subcontracting_conversion_factor - service_item.fg_item_qty = item.qty - service_item.amount = service_item.qty * service_item.rate - - def populate_items_table(self): - items = [] - - for si in self.service_items: - if si.fg_item: - item = frappe.get_doc("Item", si.fg_item) - - so_item = frappe.get_doc("Sales Order Item", si.sales_order_item) - available_qty = so_item.stock_qty - so_item.subcontracted_qty - - if available_qty == 0: - continue - - si.required_qty = available_qty - conversion_factor = so_item.stock_qty / so_item.fg_item_qty - si.fg_item_qty = flt( - available_qty / conversion_factor, frappe.get_precision("Sales Order Item", "qty") - ) - si.amount = available_qty * si.rate - - bom = ( - frappe.db.get_value( - "Subcontracting BOM", - {"finished_good": item.name, "is_active": 1}, - "finished_good_bom", - ) - or item.default_bom - ) - - items.append( - { - "item_code": item.name, - "item_name": item.item_name, - "expected_delivery_date": frappe.get_cached_value( - "Sales Order Item", si.sales_order_item, "delivery_date" - ), - "description": item.description, - "qty": si.fg_item_qty, - "subcontracting_conversion_factor": conversion_factor, - "stock_uom": item.stock_uom, - "bom": bom, - "sales_order_item": si.sales_order_item, - } - ) - else: - frappe.throw( - _("Please select Finished Good Item for Service Item {0}").format( - si.item_name or si.item_code - ) - ) - - if items: - for item in items: - self.append("items", item) - - def validate_customer_provided_items(self): - """Check if atleast one raw material is customer provided""" - for item in self.get("items"): - raw_materials = [rm for rm in self.get("received_items") if rm.main_item_code == item.item_code] - if not any([rm.is_customer_provided_item for rm in raw_materials]): - frappe.throw( - _( - "Atleast one raw material for Finished Good Item {0} should be customer provided." - ).format(frappe.bold(item.item_code)) - ) - - def set_is_customer_provided_item(self): - for item in self.get("received_items"): - item.is_customer_provided_item = frappe.get_cached_value( - "Item", item.rm_item_code, "is_customer_provided_item" - ) - - @frappe.whitelist() - def make_work_order(self): - """Create Work Order from Subcontracting Inward Order.""" - wo_list = [] - - for item in self.get_production_items(): - work_order = self.create_work_order(item) - if work_order: - wo_list.append(work_order) - - self.show_list_created_message("Work Order", wo_list) - - if not wo_list: - frappe.msgprint(_("No Work Orders were created")) - - return wo_list - - def get_production_items(self): - item_list = [] - - for d in self.items: - if d.produced_qty >= d.qty: - continue - - item_details = { - "production_item": d.item_code, - "use_multi_level_bom": d.include_exploded_items, - "subcontracting_inward_order": self.name, - "bom_no": d.bom, - "stock_uom": d.stock_uom, - "company": self.company, - "project": frappe.get_cached_value("Sales Order", self.sales_order, "project"), - "source_warehouse": self.customer_warehouse, - "subcontracting_inward_order_item": d.name, - "reserve_stock": 1, - "fg_warehouse": d.delivery_warehouse, - } - - qty = min( - [ - flt( - (item.received_qty - item.returned_qty - item.work_order_qty) - / flt(item.required_qty / d.qty, d.precision("qty")), - d.precision("qty"), - ) - for item in self.get("received_items") - if item.reference_name == d.name and item.is_customer_provided_item and item.required_qty - ] - ) - qty = min( - int(qty) if frappe.get_cached_value("UOM", d.stock_uom, "must_be_whole_number") else qty, - d.qty - d.produced_qty, - ) - - item_details.update({"qty": qty, "max_producible_qty": qty}) - item_list.append(item_details) - - return item_list - - def create_work_order(self, item): - from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError - - if flt(item.get("qty")) <= 0: - return - - wo = frappe.new_doc("Work Order") - wo.update(item) - - wo.set_work_order_operations() - wo.set_required_items() - - try: - wo.flags.ignore_mandatory = True - wo.flags.ignore_validate = True - wo.insert() - return wo.name - except OverProductionError: - pass - - def show_list_created_message(self, doctype, doc_list=None): - if not doc_list: - return - - frappe.flags.mute_messages = False - if doc_list: - doc_list = [get_link_to_form(doctype, p) for p in doc_list] - frappe.msgprint(_("{0} created").format(comma_and(doc_list))) - - @frappe.whitelist() - def make_rm_stock_entry_inward(self, target_doc=None): - def calculate_qty_as_per_bom(rm_item): - data = frappe.get_value( - "Subcontracting Inward Order Item", - {"name": rm_item.reference_name}, - ["process_loss_qty", "include_exploded_items"], - as_dict=True, - ) - stock_qty = frappe.get_value( - "BOM Explosion Item" if data.include_exploded_items else "BOM Item", - {"name": rm_item.bom_detail_no}, - "stock_qty", - ) - qty = flt( - stock_qty * data.process_loss_qty, - frappe.get_precision("Subcontracting Inward Order Received Item", "required_qty"), - ) - return rm_item.required_qty - rm_item.received_qty + rm_item.returned_qty + qty - - if target_doc and target_doc.get("items"): - target_doc.items = [] - - stock_entry = get_mapped_doc( - "Subcontracting Inward Order", - self.name, - { - "Subcontracting Inward Order": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - }, - }, - }, - target_doc, - ignore_child_tables=True, - ) - - stock_entry.purpose = "Receive from Customer" - stock_entry.subcontracting_inward_order = self.name - - stock_entry.set_stock_entry_type() - - for rm_item in self.received_items: - if not rm_item.required_qty or not rm_item.is_customer_provided_item: - continue - - items_dict = { - rm_item.get("rm_item_code"): { - "scio_detail": rm_item.get("name"), - "qty": calculate_qty_as_per_bom(rm_item), - "to_warehouse": rm_item.get("warehouse"), - "stock_uom": rm_item.get("stock_uom"), - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - - if target_doc: - return stock_entry - else: - return stock_entry.as_dict() - - @frappe.whitelist() - def make_rm_return(self, target_doc=None): - if target_doc and target_doc.get("items"): - target_doc.items = [] - - stock_entry = get_mapped_doc( - "Subcontracting Inward Order", - self.name, - { - "Subcontracting Inward Order": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - }, - }, - }, - target_doc, - ignore_child_tables=True, - ) - - stock_entry.purpose = "Return Raw Material to Customer" - stock_entry.set_stock_entry_type() - stock_entry.subcontracting_inward_order = self.name - - for rm_item in self.received_items: - items_dict = { - rm_item.get("rm_item_code"): { - "scio_detail": rm_item.get("name"), - "qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty, - "from_warehouse": rm_item.get("warehouse"), - "stock_uom": rm_item.get("stock_uom"), - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - - if target_doc: - return stock_entry - else: - return stock_entry.as_dict() - - @frappe.whitelist() - def make_subcontracting_delivery(self, target_doc=None): - if target_doc and target_doc.get("items"): - target_doc.items = [] - - stock_entry = get_mapped_doc( - "Subcontracting Inward Order", - self.name, - { - "Subcontracting Inward Order": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - }, - }, - }, - target_doc, - ignore_child_tables=True, - ) - - stock_entry.purpose = "Subcontracting Delivery" - stock_entry.set_stock_entry_type() - stock_entry.subcontracting_inward_order = self.name - scio_details = [] - - allow_over = frappe.get_single_value("Selling Settings", "allow_delivery_of_overproduced_qty") - for fg_item in self.items: - qty = ( - fg_item.produced_qty - if allow_over - else min(fg_item.qty, fg_item.produced_qty) - fg_item.delivered_qty - ) - if qty < 0: - continue - - scio_details.append(fg_item.name) - items_dict = { - fg_item.item_code: { - "qty": qty, - "from_warehouse": fg_item.delivery_warehouse, - "stock_uom": fg_item.stock_uom, - "scio_detail": fg_item.name, - "is_finished_item": 1, - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - - if ( - frappe.get_single_value("Selling Settings", "deliver_secondary_items") - and self.secondary_items - and scio_details - ): - secondary_items = [ - secondary_item - for secondary_item in self.secondary_items - if secondary_item.reference_name in scio_details - ] - for secondary_item in secondary_items: - qty = secondary_item.produced_qty - secondary_item.delivered_qty - if qty > 0: - items_dict = { - secondary_item.item_code: { - "qty": secondary_item.produced_qty - secondary_item.delivered_qty, - "from_warehouse": secondary_item.warehouse, - "stock_uom": secondary_item.stock_uom, - "scio_detail": secondary_item.name, - "type": secondary_item.type, - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - - if target_doc: - return stock_entry - else: - return stock_entry.as_dict() - - @frappe.whitelist() - def make_subcontracting_return(self, target_doc=None): - if target_doc and target_doc.get("items"): - target_doc.items = [] - - stock_entry = get_mapped_doc( - "Subcontracting Inward Order", - self.name, - { - "Subcontracting Inward Order": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - }, - "field_map": {"name": "subcontracting_inward_order"}, - }, - }, - target_doc, - ignore_child_tables=True, - ) - - stock_entry.purpose = "Subcontracting Return" - stock_entry.set_stock_entry_type() - - for fg_item in self.items: - qty = fg_item.delivered_qty - fg_item.returned_qty - if qty < 0: - continue - - items_dict = { - fg_item.item_code: { - "qty": qty, - "stock_uom": fg_item.stock_uom, - "scio_detail": fg_item.name, - "is_finished_item": 1, - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - - if target_doc: - return stock_entry - else: - return stock_entry.as_dict() - - -@frappe.whitelist() -def update_subcontracting_inward_order_status(scio: str | Document, status: str | None = None): - if isinstance(scio, str): - scio = frappe.get_doc("Subcontracting Inward Order", scio) - - scio.check_permission("write") - scio.update_status(status) From ef3046dca2f3571f5c76cf1e8c82bd7515ce5374 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 13 Jun 2026 19:09:04 +0530 Subject: [PATCH 23/52] fix: permission in bom compare tool (cherry picked from commit e6fdb3702aa0fe017f498727fac8286074802471) --- .../bom_comparison_tool/bom_comparison_tool.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js index fcb7e884ecc..753fb4e896d 100644 --- a/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js +++ b/erpnext/manufacturing/page/bom_comparison_tool/bom_comparison_tool.js @@ -96,8 +96,8 @@ erpnext.BOMComparisonTool = class BOMComparisonTool { return ` ${frappe.meta.get_label(doctype, fieldname)} - ${value1} - ${value2} + ${frappe.utils.escape_html(cstr(value1))} + ${frappe.utils.escape_html(cstr(value2))} `; }) @@ -138,13 +138,17 @@ erpnext.BOMComparisonTool = class BOMComparisonTool { .map((change, i) => { let [fieldname, value1, value2] = change; let th = - i === 0 ? `${item_code}` : ""; + i === 0 + ? `${frappe.utils.escape_html( + cstr(item_code) + )}` + : ""; return ` ${th} ${frappe.meta.get_label(child_doctype, fieldname)} - ${value1} - ${value2} + ${frappe.utils.escape_html(cstr(value1))} + ${frappe.utils.escape_html(cstr(value2))} `; }) @@ -177,7 +181,9 @@ erpnext.BOMComparisonTool = class BOMComparisonTool { let html = rows .map((row) => { let [, doc] = row; - let cells = fields.map((df) => `${doc[df.fieldname]}`).join(""); + let cells = fields + .map((df) => `${frappe.utils.escape_html(cstr(doc[df.fieldname]))}`) + .join(""); return `${cells}`; }) .join(""); From 8b3a0fe04571c9f48f74bf68fb3ff160269b5151 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:19:13 +0000 Subject: [PATCH 24/52] fix(Lead): stop storing Gravatar image URLs for Leads (backport #55880) (#55881) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> fix(Lead): stop storing Gravatar image URLs for Leads (#55880) --- erpnext/crm/doctype/lead/lead.py | 5 +---- erpnext/templates/includes/projects.css | 4 ---- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 0f901b5eff6..b92a0db08d9 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -9,7 +9,7 @@ from frappe.contacts.address_and_contact import ( ) from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc -from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address +from frappe.utils import comma_and, get_link_to_form, validate_email_address from erpnext.accounts.party import set_taxes from erpnext.controllers.selling_controller import SellingController @@ -171,9 +171,6 @@ class Lead(SellingController, CRMNote): if self.email_id == self.lead_owner: frappe.throw(_("Lead Owner cannot be same as the Lead Email Address")) - if self.is_new() or not self.image: - self.image = has_gravatar(self.email_id) - def link_to_contact(self): # update contact links if self.contact_doc: diff --git a/erpnext/templates/includes/projects.css b/erpnext/templates/includes/projects.css index 5d9fc50385e..0ee177442c9 100644 --- a/erpnext/templates/includes/projects.css +++ b/erpnext/templates/includes/projects.css @@ -79,10 +79,6 @@ padding: 8px; } -.gravatar-top{ - margin-top:8px; -} - .progress-hg{ margin-bottom: 30!important; height:2px; From fe3f44f64354fb178692a852cfe54cd847e4bcc6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:03:30 +0000 Subject: [PATCH 25/52] ci: set disabledLabels and context for greptile (backport #55883) (#55884) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- .greptile/config.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .greptile/config.json diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000000..8d9c41c662e --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,10 @@ +{ + "disabledLabels": [ + "conflicts" + ], + "context": { + "repos": [ + "frappe/frappe" + ] + } +} From 7e9c1efab72057cd1fad10ef2e027df99ef102e7 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 2 Jun 2026 17:19:25 +0530 Subject: [PATCH 26/52] fix(manufacturing): fetch raw materials from work order --- .../stock/doctype/stock_entry/stock_entry.py | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 87715e1e516..1f0616a771f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2467,9 +2467,24 @@ class StockEntry(StockController): ): self.get_unconsumed_raw_materials() + elif self.pro_doc and ( + self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture" + ): + if not self.fg_completed_qty: + frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label("fg_completed_qty")))) + + item_dict = self.get_work_order_raw_materials(self.fg_completed_qty) + + for item in item_dict.values(): + if self.pro_doc.from_wip_warehouse: + item["from_warehouse"] = self.pro_doc.wip_warehouse + item["to_warehouse"] = "" + + self.add_to_stock_entry_detail(item_dict) + else: if not self.fg_completed_qty: - frappe.throw(_("Manufacturing Quantity is mandatory")) + frappe.throw(_("{0} is mandatory").format(_(self.meta.get_label("fg_completed_qty")))) item_dict = self.get_bom_raw_materials(self.fg_completed_qty) @@ -2724,6 +2739,56 @@ class StockEntry(StockController): return item_dict + def get_work_order_raw_materials(self, qty): + item_dict = frappe._dict() + + used_alternative_items = get_used_alternative_items( + subcontract_order_field=self.subcontract_data.order_field, work_order=self.work_order + ) + + for d in self.pro_doc.get("required_items"): + item_qty = flt( + (d.required_qty / self.pro_doc.qty) * qty, frappe.get_precision("Stock Entry Detail", "qty") + ) + from_warehouse = ( + d.source_warehouse + if self.pro_doc.skip_transfer and not self.pro_doc.from_wip_warehouse + else self.from_warehouse or d.source_warehouse + ) + + item_row = frappe._dict( + { + "item_code": d.item_code, + "item_name": d.item_name, + "description": d.description, + "qty": item_qty, + "stock_uom": d.stock_uom, + "uom": d.stock_uom, + "conversion_factor": 1, + "from_warehouse": from_warehouse, + "allow_alternative_item": d.allow_alternative_item + and self.pro_doc.allow_alternative_item, + } + ) + + if d.item_code in used_alternative_items: + alt = used_alternative_items.get(d.item_code) + item_row.update( + { + "item_code": alt.item_code, + "item_name": alt.item_name, + "stock_uom": alt.stock_uom, + "uom": alt.uom, + "conversion_factor": alt.conversion_factor, + "description": alt.description, + "original_item": d.item_code, + } + ) + + item_dict[d.item_code] = item_row + + return item_dict + def get_bom_scrap_material(self, qty): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict From 3356583865708b8ec1bbd595c958b9bef6ac6c79 Mon Sep 17 00:00:00 2001 From: Raghav Ruia Date: Mon, 15 Jun 2026 09:26:09 +0530 Subject: [PATCH 27/52] fix: show company name in delete transactions confirmation dialog Display the actual company name in bold within the confirmation dialog label so users immediately know which company they must type to confirm, reducing the risk of accidental data loss. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 87d26a2d678a762461b0d85c4f8071890c7f6d3e) --- erpnext/setup/doctype/company/company.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 032ec707330..f4de4bd2923 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -204,7 +204,9 @@ frappe.ui.form.on("Company", { { fieldtype: "Data", fieldname: "company_name", - label: __("Please enter the company name to confirm"), + label: __('Please enter the company name "{0}" to confirm', [ + frappe.utils.escape_html(frm.doc.name), + ]), reqd: 1, description: __( "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone." From 70628c06c99d279b59acc5619ab0e77d4077349a Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Tue, 9 Jun 2026 11:58:30 +0530 Subject: [PATCH 28/52] feat(currency exchange settings): frankfurter v2 support (cherry picked from commit 56bfe6b6a695b2d93ceedf8d50082aa732789b2a) # Conflicts: # erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json --- .../currency_exchange_settings.js | 18 ++++++++++++------ .../currency_exchange_settings.json | 7 ++++++- .../currency_exchange_settings.py | 19 +++++++++++++++++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js index 40f0938ee1c..950092a2382 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.js @@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", { }, callback: function (r) { if (r && r.message) { + let result = [], + params = {}; if (frm.doc.service_provider == "exchangerate.host") { - let result = ["result"]; - let params = { + result = ["result"]; + params = { date: "{transaction_date}", from: "{from_currency}", to: "{to_currency}", }; - add_param(frm, r.message, params, result); } else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) { - let result = ["rates", "{to_currency}"]; - let params = { + result = ["rates", "{to_currency}"]; + params = { base: "{from_currency}", symbols: "{to_currency}", }; - add_param(frm, r.message, params, result); + } else if (frm.doc.service_provider == "frankfurter.dev - v2") { + result = ["rate"]; + params = { + date: "{transaction_date}", + }; } + add_param(frm, r.message, params, result); } }, }); diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json index 614f4e6d3e5..d8c22fe2b97 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2022-01-10 13:03:26.237081", "doctype": "DocType", "editable_grid": 1, @@ -78,7 +79,7 @@ "fieldname": "service_provider", "fieldtype": "Select", "label": "Service Provider", - "options": "frankfurter.dev\nexchangerate.host\nCustom", + "options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom", "reqd": 1 }, { @@ -104,7 +105,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2025-11-25 13:03:41.896424", +======= + "modified": "2026-06-09 11:34:10.432378", +>>>>>>> 56bfe6b6a6 (feat(currency exchange settings): frankfurter v2 support) "modified_by": "Administrator", "module": "Accounts", "name": "Currency Exchange Settings", diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py index 28e4158cc8d..e57805ccea8 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.py @@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document): disabled: DF.Check req_params: DF.Table[CurrencyExchangeSettingsDetails] result_key: DF.Table[CurrencyExchangeSettingsResult] - service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"] + service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"] url: DF.Data | None use_http: DF.Check # end: auto-generated types @@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document): self.append("req_params", {"key": "base", "value": "{from_currency}"}) self.append("req_params", {"key": "symbols", "value": "{to_currency}"}) + elif self.service_provider == "frankfurter.dev - v2": + self.set("result_key", []) + self.set("req_params", []) + + self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http) + self.append("result_key", {"key": "rate"}) + self.append("req_params", {"key": "date", "value": "{transaction_date}"}) + def validate_parameters(self): params = {} for row in self.req_params: @@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document): @frappe.whitelist() def get_api_endpoint(service_provider: str | None = None, use_http: bool = False): - if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]: + if service_provider and service_provider in [ + "exchangerate.host", + "frankfurter.dev", + "frankfurter.app", + "frankfurter.dev - v2", + ]: if service_provider == "exchangerate.host": api = "api.exchangerate.host/convert" elif service_provider == "frankfurter.app": api = "api.frankfurter.app/{transaction_date}" elif service_provider == "frankfurter.dev": api = "api.frankfurter.dev/v1/{transaction_date}" + elif service_provider == "frankfurter.dev - v2": + api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}" protocol = "https://" if use_http: From d62985d9a76ca474b138a71b97cd9a98d342b7e1 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Tue, 9 Jun 2026 12:17:18 +0530 Subject: [PATCH 29/52] fix: use frankfurter v2 by default for new install (cherry picked from commit 479f9f63c9b3e26410e67190b1e8cf4e47832b19) --- erpnext/setup/install.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 8c288b783c1..03fc31b253e 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -90,14 +90,7 @@ def set_single_defaults(): def setup_currency_exchange(): ces = frappe.get_single("Currency Exchange Settings") try: - ces.set("result_key", []) - ces.set("req_params", []) - - ces.api_endpoint = "https://api.frankfurter.dev/v1/{transaction_date}" - ces.append("result_key", {"key": "rates"}) - ces.append("result_key", {"key": "{to_currency}"}) - ces.append("req_params", {"key": "base", "value": "{from_currency}"}) - ces.append("req_params", {"key": "symbols", "value": "{to_currency}"}) + ces.service_provider = "frankfurter.dev - v2" ces.save() except frappe.ValidationError: pass From 64fc3ac309c0144f1c51c9e9f438fd6104728222 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Tue, 9 Jun 2026 23:00:41 +0530 Subject: [PATCH 30/52] test: fixed currency exchange test for frankfurter v2 api (cherry picked from commit 138f683a68acb6c698beb26821299264cc1e4e73) --- .../doctype/currency_exchange/test_currency_exchange.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py index 86fae223b45..6cece137366 100644 --- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py +++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py @@ -68,13 +68,16 @@ def patched_requests_get(*args, **kwargs): if kwargs["params"].get("date") and kwargs["params"].get("from") and kwargs["params"].get("to"): if test_exchange_values.get(kwargs["params"]["date"]): return PatchResponse({"result": test_exchange_values[kwargs["params"]["date"]]}, 200) - elif args[0].startswith("https://api.frankfurter.dev") and kwargs.get("params"): + elif args[0].startswith("https://api.frankfurter.dev/v1") and kwargs.get("params"): if kwargs["params"].get("base") and kwargs["params"].get("symbols"): date = args[0].replace("https://api.frankfurter.dev/v1/", "") if test_exchange_values.get(date): return PatchResponse( {"rates": {kwargs["params"].get("symbols"): test_exchange_values.get(date)}}, 200 ) + elif args[0].startswith("https://api.frankfurter.dev/v2") and kwargs.get("params"): + if kwargs["params"].get("date") and test_exchange_values.get(kwargs["params"]["date"]): + return PatchResponse({"rate": test_exchange_values.get(kwargs["params"]["date"])}, 200) return PatchResponse({"rates": None}, 404) From 7b6520664c90c510cb94e98af5172c6d19c77a1e Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 15 Jun 2026 11:27:26 +0530 Subject: [PATCH 31/52] fix: restricting currency_exchange_settings write permission only to system manager (cherry picked from commit 0c2d5488a64325724afe4b3f925a9be92e000a4f) # Conflicts: # erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json --- .../currency_exchange_settings.json | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json index d8c22fe2b97..388546dcf56 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json @@ -1,6 +1,5 @@ { "actions": [], - "allow_bulk_edit": 1, "creation": "2022-01-10 13:03:26.237081", "doctype": "DocType", "editable_grid": 1, @@ -105,11 +104,15 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD <<<<<<< HEAD "modified": "2025-11-25 13:03:41.896424", ======= "modified": "2026-06-09 11:34:10.432378", >>>>>>> 56bfe6b6a6 (feat(currency exchange settings): frankfurter v2 support) +======= + "modified": "2026-06-15 11:25:55.873110", +>>>>>>> 0c2d5488a6 (fix: restricting currency_exchange_settings write permission only to system manager) "modified_by": "Administrator", "module": "Accounts", "name": "Currency Exchange Settings", @@ -126,24 +129,11 @@ "write": 1 }, { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "Accounts Manager", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, "email": 1, "print": 1, "read": 1, "role": "Accounts User", - "share": 1, - "write": 1 + "share": 1 } ], "row_format": "Dynamic", From 9405b49e93b9ce1c04bc3cca78b848325fe7a6dc Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 15 Jun 2026 13:50:38 +0530 Subject: [PATCH 32/52] fix(get_exchange_rate): using get_single_value to fetch `disabled` value from `currency_exchange_settings` (cherry picked from commit abb579e2db7ac7705b859f8fb037fac7a0de96be) --- erpnext/setup/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py index b7436a140f8..7de420ceea9 100644 --- a/erpnext/setup/utils.py +++ b/erpnext/setup/utils.py @@ -130,7 +130,7 @@ def get_exchange_rate(from_currency, to_currency, transaction_date=None, args=No if entries: return flt(entries[0].exchange_rate) - if frappe.get_cached_value("Currency Exchange Settings", "Currency Exchange Settings", "disabled"): + if frappe.get_single_value("Currency Exchange Settings", "disabled"): return 0.00 pegged_currencies = {} From 80741ceb679ba7b3f612f4f4abb54433a2c324c5 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Mon, 15 Jun 2026 14:39:29 +0530 Subject: [PATCH 33/52] chore: resolve conflicts --- .../currency_exchange_settings.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json index 388546dcf56..a3aea6016b9 100644 --- a/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json +++ b/erpnext/accounts/doctype/currency_exchange_settings/currency_exchange_settings.json @@ -104,15 +104,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD -<<<<<<< HEAD - "modified": "2025-11-25 13:03:41.896424", -======= - "modified": "2026-06-09 11:34:10.432378", ->>>>>>> 56bfe6b6a6 (feat(currency exchange settings): frankfurter v2 support) -======= "modified": "2026-06-15 11:25:55.873110", ->>>>>>> 0c2d5488a6 (fix: restricting currency_exchange_settings write permission only to system manager) "modified_by": "Administrator", "module": "Accounts", "name": "Currency Exchange Settings", From 1f47b2417bbaffa59b0d2ebcc84352e539cf682d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:23:08 +0000 Subject: [PATCH 34/52] fix: regression issues related to security fixes (backport #55902) (#55925) * fix: regression issues related to security fixes (cherry picked from commit be1aa0e5ebcc05e3b3587333651a95aea55756cf) # Conflicts: # erpnext/controllers/subcontracting_inward_controller.py # erpnext/selling/doctype/sales_order/sales_order.py # erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py * refactor: consolidate duplicate get_party_bank_account into bank_account.py (cherry picked from commit ede13cb3bdd4aa6ec16fecdf49cd107617c5616e) * chore: fix conflicts Removed duplicate import of get_party_bank_account and consolidated imports. * chore: fix conflicts Removed the update_subcontracting_order_status method to address regression issues related to security fixes. --------- Co-authored-by: Rohit Waghchaure --- .../payment_request/payment_request.py | 3 +- erpnext/accounts/party.py | 6 -- .../doctype/purchase_order/purchase_order.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 6 +- .../subcontracting_order.py | 10 +++- .../test_subcontracting_order.py | 56 +++++++++++++++++++ 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 8ff021660cc..e341490ed7f 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -11,11 +11,12 @@ from erpnext import get_company_currency from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, ) +from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account from erpnext.accounts.doctype.payment_entry.payment_entry import ( get_payment_entry, ) from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate -from erpnext.accounts.party import get_party_account, get_party_bank_account +from erpnext.accounts.party import get_party_account from erpnext.accounts.utils import get_account_currency, get_currency_precision from erpnext.utilities import payment_app_import_guard diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 14146a7872e..36a2aeb67b1 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -510,12 +510,6 @@ def get_party_advance_account(party_type, party, company): return account -@frappe.whitelist() -def get_party_bank_account(party_type: str, party: str): - frappe.has_permission("Bank Account", "read", throw=True) - return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1}) - - def get_party_account_currency(party_type, party, company): def generator(): party_account = get_party_account(party_type, party, company) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index d80f116e042..95b67d49429 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -662,7 +662,7 @@ class PurchaseOrder(BuyingController): def update_subcontracting_order_status(self): from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( - update_subcontracting_order_status as update_sco_status, + set_subcontracting_order_status as update_sco_status, ) if self.is_subcontracted and not self.is_old_subcontracting_flow: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 87715e1e516..dd688343e2f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3410,10 +3410,12 @@ class StockEntry(StockController): def update_subcontracting_order_status(self): if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]: from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( - update_subcontracting_order_status, + set_subcontracting_order_status, ) - update_subcontracting_order_status(self.subcontracting_order) + # Trusted submit/cancel flow — a Stock operation must not require Subcontracting Order + # write permission, so use the no-check internal helper (not the whitelisted boundary). + set_subcontracting_order_status(self.subcontracting_order) def update_pick_list_status(self): from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 7a8d4d62124..2bc39655a97 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -364,10 +364,18 @@ def get_mapped_subcontracting_receipt(source_name, target_doc=None): return target_doc +def set_subcontracting_order_status(sco: str | Document, status: str | None = None): + if isinstance(sco, str): + sco = frappe.get_doc("Subcontracting Order", sco) + + sco.update_status(status) + + @frappe.whitelist() def update_subcontracting_order_status(sco: str | Document, status: str | None = None): + """Whitelisted boundary for direct API/UI calls — enforces write permission, then delegates.""" if isinstance(sco, str): sco = frappe.get_doc("Subcontracting Order", sco) sco.check_permission("write") - sco.update_status(status) + set_subcontracting_order_status(sco, status) diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 43592b628ad..6094229c32d 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -336,6 +336,62 @@ class TestSubcontractingOrder(FrappeTestCase): bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract ) + def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self): + """A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be + able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the + on_submit/on_cancel path goes through the no-permission-check internal helper, not the + whitelisted API boundary. + + Regression: the permission hardening put check_permission('write') on the shared status + function, so a Stock Manager (no SCO write) hit PermissionError submitting/cancelling the + Stock Entry. The suite otherwise runs as Administrator and never caught it.""" + from frappe.core.doctype.user_permission.test_user_permission import create_user + + make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 100, + "fg_item": "_Test FG Item", + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + + rm_items = [ + { + "item_code": "_Test FG Item", + "rm_item_code": "_Test Item", + "item_name": "_Test Item", + "qty": 10, + "warehouse": "_Test Warehouse - _TC", + "rate": 100, + "amount": 1000, + "stock_uom": "Nos", + }, + ] + ste = frappe.get_doc(make_rm_stock_entry(sco.name, rm_items)) + ste.to_warehouse = "_Test Warehouse 1 - _TC" + ste.save() + + stock_user = create_user("test_sco_stock_only@example.com", "Stock Manager") + self.assertFalse( + frappe.has_permission("Subcontracting Order", "write", user=stock_user.name), + "Precondition: the Stock-only user must not have Subcontracting Order write permission.", + ) + + frappe.set_user(stock_user.name) + try: + ste.reload() + ste.submit() # must not raise PermissionError on the SCO status update + ste.reload() + ste.cancel() # same on the cancel path + finally: + frappe.set_user("Administrator") + def test_exploded_items(self): item_code = "_Test Subcontracted FG Item 11" make_subcontracted_item(item_code=item_code) From 081887bec5b7daead28377b9b8b0d7f338d6b2ab Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 15 Jun 2026 16:08:38 +0530 Subject: [PATCH 35/52] fix: recalculate incoming rate in SLE for purchase documents during repost (cherry picked from commit 867ee484b90b5afce3760fcad8f23a8a954e04a0) --- erpnext/stock/stock_ledger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a1dc9d13a4e..a28b27aac5d 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1188,6 +1188,7 @@ class update_entries_after: sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle) or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no)) + or (sle.voucher_type in ("Purchase Receipt", "Purchase Invoice")) ): rate = self.get_incoming_outgoing_rate_from_transaction(sle) From 18ad3238286ecf540232f46e9c6ab917c6e74820 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 15 Jun 2026 16:27:18 +0530 Subject: [PATCH 36/52] fix(stock): update stock value calculation in stock balance report (cherry picked from commit 2492dfa5584238d349463565608b82fba1733f7f) --- erpnext/stock/report/stock_balance/stock_balance.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 0f09cc867c2..6bceb0483f8 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -277,12 +277,13 @@ class StockBalanceReport: qty_dict.opening_qty -= self.stock_reco_voucher_wise_count.get(entry.voucher_detail_no, 0) qty_dict.bal_qty = 0.0 qty_diff = flt(entry.actual_qty) + value_diff = flt(entry.stock_value_difference) else: qty_diff = flt(entry.qty_after_transaction) - flt(qty_dict.bal_qty) + value_diff = flt(entry.stock_value) - flt(qty_dict.bal_val) else: qty_diff = flt(entry.actual_qty) - - value_diff = flt(entry.stock_value_difference) + value_diff = flt(entry.stock_value_difference) if entry.posting_date < self.from_date or entry.voucher_no in self.opening_vouchers.get( entry.voucher_type, [] From e1fbf78409d788e34c3e2021eebb0bb0e760596a Mon Sep 17 00:00:00 2001 From: Shllokkk <140623894+Shllokkk@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:43:52 +0530 Subject: [PATCH 37/52] fix(budget): add root_type filter on account field (#55934) (cherry picked from commit 52b406f5f177a98d8ecefea953fb4af4a55dd8ae) # Conflicts: # erpnext/accounts/doctype/budget/budget.js --- erpnext/accounts/doctype/budget/budget.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 6e874f7c08c..587ef11642a 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -22,6 +22,19 @@ frappe.ui.form.on("Budget", { }; }); +<<<<<<< HEAD +======= + frm.set_query("account", function () { + return { + filters: { + is_group: 0, + company: frm.doc.company, + root_type: ["in", ["Income", "Expense"]], + }, + }; + }); + +>>>>>>> 52b406f5f1 (fix(budget): add root_type filter on account field (#55934)) erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, From 72c7b79933c2569fa11f329c822316345151fbc5 Mon Sep 17 00:00:00 2001 From: ervishnucs Date: Wed, 4 Mar 2026 12:40:52 +0530 Subject: [PATCH 38/52] fix: prevent exchange rate flow from transaction to payment (cherry picked from commit 934abe5c6d33af63d9c8922e34ff5a1d48775d28) --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 5a2c90a89d5..26c980a1946 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3038,7 +3038,7 @@ def get_payment_entry( pe, doc, discount_amount, base_total_discount_loss, party_account_currency ) - pe.set_exchange_rate(ref_doc=doc) + pe.set_exchange_rate() pe.set_amounts() # If PE is created from PR directly, then no need to find open PRs for the references From df823432d18456b6e073fa34255dfd0e2b029b8f Mon Sep 17 00:00:00 2001 From: ervishnucs Date: Sun, 5 Apr 2026 22:21:41 +0530 Subject: [PATCH 39/52] test: resolve failed testcases for exchage rate (cherry picked from commit 3b5a203d61aa980be35f0ba915f07f49afce7a9c) --- .../doctype/payment_entry/test_payment_entry.py | 4 ++++ .../payment_request/test_payment_request.py | 14 ++++++++++++-- erpnext/accounts/test/test_utils.py | 2 ++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 0a8b69206ed..79bd66f735a 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -537,6 +537,8 @@ class TestPaymentEntry(FrappeTestCase): si.submit() pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700) + pe.source_exchange_rate = 50 + pe.set_amounts() pe.reference_no = si.name pe.reference_date = nowdate() @@ -612,6 +614,8 @@ class TestPaymentEntry(FrappeTestCase): pe = get_payment_entry( "Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900 ) + pe.source_exchange_rate = 50 + pe.set_amounts() pe.reference_no = "1" pe.reference_date = "2016-01-01" diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index ae8ccffc639..df28b623488 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -195,7 +195,12 @@ class TestPaymentRequest(FrappeTestCase): return_doc=1, ) - pe = pr.set_as_paid() + pe = pr.create_payment_entry(submit=False) + pe.source_exchange_rate = 50 + pe.target_exchange_rate = 50 + pe.set_amounts() + pe.insert(ignore_permissions=True) + pe.submit() expected_gle = dict( (d[0], d) @@ -281,7 +286,12 @@ class TestPaymentRequest(FrappeTestCase): pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com") pr = frappe.get_doc(pr).save().submit() - pe = pr.create_payment_entry() + pe = pr.create_payment_entry(submit=False) + pe.target_exchange_rate = 80 + pe.paid_amount = 800 + pe.set_amounts() + pe.insert(ignore_permissions=True) + pe.submit() self.assertEqual(pe.base_paid_amount, 800) self.assertEqual(pe.paid_amount, 800) self.assertEqual(pe.base_received_amount, 800) diff --git a/erpnext/accounts/test/test_utils.py b/erpnext/accounts/test/test_utils.py index f89b47b2dfe..b86c8161f03 100644 --- a/erpnext/accounts/test/test_utils.py +++ b/erpnext/accounts/test/test_utils.py @@ -89,6 +89,8 @@ class TestUtils(unittest.TestCase): purchase_invoice.submit() payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name) + payment_entry.target_exchange_rate = 82.32 + payment_entry.set_amounts() payment_entry.paid_amount = 15725 payment_entry.deductions = [] payment_entry.save() From 8f23f1f180a2d26b404f8dc69caf2fb7103fc4d7 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 16 Jun 2026 15:30:51 +0530 Subject: [PATCH 40/52] Revert "fix(budget): add root_type filter on account field (backport #55934)" (#55966) --- erpnext/accounts/doctype/budget/budget.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.js b/erpnext/accounts/doctype/budget/budget.js index 587ef11642a..6e874f7c08c 100644 --- a/erpnext/accounts/doctype/budget/budget.js +++ b/erpnext/accounts/doctype/budget/budget.js @@ -22,19 +22,6 @@ frappe.ui.form.on("Budget", { }; }); -<<<<<<< HEAD -======= - frm.set_query("account", function () { - return { - filters: { - is_group: 0, - company: frm.doc.company, - root_type: ["in", ["Income", "Expense"]], - }, - }; - }); - ->>>>>>> 52b406f5f1 (fix(budget): add root_type filter on account field (#55934)) erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); }, From 9546ab72e0f1ee5eb0a3668a58b24f11dcdd479e Mon Sep 17 00:00:00 2001 From: ervishnucs Date: Mon, 15 Jun 2026 18:22:30 +0530 Subject: [PATCH 41/52] refactor: ignore cancelled GLE's while looking for account (cherry picked from commit 40942401df8bda9baaa47e390fee2158465c6e8e) --- erpnext/accounts/party.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 36a2aeb67b1..d02230f0ee7 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -544,11 +544,19 @@ def get_party_gle_currency(party_type, party, company): def get_party_gle_account(party_type, party, company): def generator(): - existing_gle_account = frappe.db.sql( - """select account from `tabGL Entry` - where docstatus=1 and company=%(company)s and party_type=%(party_type)s and party=%(party)s - limit 1""", - {"company": company, "party_type": party_type, "party": party}, + gl = qb.DocType("GL Entry") + existing_gle_account = ( + qb.from_(gl) + .select(gl.account) + .where( + (gl.docstatus == 1) + & (gl.company == company) + & (gl.party_type == party_type) + & (gl.party == party) + & (gl.is_cancelled == 0) + ) + .limit(1) + .run() ) return existing_gle_account[0][0] if existing_gle_account else None From b22096a640715963fe42566eba3a0e0c535e0611 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 16 Jun 2026 16:51:30 +0530 Subject: [PATCH 42/52] test(manufacturing): add test to validate the transferred raw materials from work order --- .../doctype/work_order/test_work_order.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f4a0d6f6145..bf56182ee41 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3631,6 +3631,58 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0) + @change_settings( + "Manufacturing Settings", + {"allow_editing_of_items_and_quantities_in_work_order": 1}, + ) + def test_manufacture_se_fetches_edited_qty_from_work_order(self): + """When a raw material qty is edited on the Work Order, the Manufacture Stock Entry + must consume the edited quantity (scaled to fg_completed_qty) from the Work Order, + not the original BOM quantity.""" + warehouse = "_Test Warehouse - _TC" + wo_order = make_wo_order_test_record( + item="_Test FG Item", qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + # edit a required item's qty + wo_order.required_items[0].db_set("required_qty", flt(wo_order.required_items[0].required_qty) + 7) + wo_order.reload() + edited_row = wo_order.required_items[0] + + fg_qty = 5 + se = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", fg_qty)) + se_qty = {row.item_code: row.qty for row in se.items if row.s_warehouse} + + precision = frappe.get_precision("Stock Entry Detail", "qty") + expected = flt(edited_row.required_qty / wo_order.qty * fg_qty, precision) + self.assertEqual(flt(se_qty.get(edited_row.item_code)), expected) + + @change_settings( + "Manufacturing Settings", + {"allow_editing_of_items_and_quantities_in_work_order": 1}, + ) + def test_manufacture_se_fetches_item_not_in_bom_from_work_order(self): + """A raw material that is present on the Work Order but not on the BOM must still be + fetched into the Manufacture Stock Entry, proving items are sourced from the Work + Order's required_items rather than re-derived from the BOM.""" + extra_item = make_item( + "_Test WO Extra Raw Material", {"is_stock_item": 1, "valuation_rate": 100} + ).name + warehouse = "_Test Warehouse - _TC" + wo_order = make_wo_order_test_record( + item="_Test FG Item", qty=10, skip_transfer=1, source_warehouse=warehouse + ) + + original_item = wo_order.required_items[0].item_code + wo_order.required_items[0].db_set("item_code", extra_item) + wo_order.reload() + + se = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) + se_items = [row.item_code for row in se.items if row.s_warehouse] + + self.assertIn(extra_item, se_items) + self.assertNotIn(original_item, se_items) + def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import ( From 7f30a2cfb6d85f0a513935d1db75752e04bc1792 Mon Sep 17 00:00:00 2001 From: Dharanidharan2813 Date: Tue, 16 Jun 2026 17:00:15 +0530 Subject: [PATCH 43/52] fix(stock): show only batched items in batch item selector (cherry picked from commit 62f6d18143cd39801a2bd689e5f149a03177ae2e) # Conflicts: # erpnext/stock/doctype/batch/batch.json --- erpnext/stock/doctype/batch/batch.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 4bc14a976d6..8fdf8ae5055 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -58,6 +58,7 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Item", + "link_filters": "[\n [\"Item\", \"has_batch_no\", \"=\", 1],\n [\"Item\", \"is_stock_item\", \"=\", 1]\n]", "oldfieldname": "item", "oldfieldtype": "Link", "options": "Item", @@ -208,7 +209,11 @@ "image_field": "image", "links": [], "max_attachments": 5, +<<<<<<< HEAD "modified": "2023-11-09 12:17:28.339975", +======= + "modified": "2026-06-16 16:01:26.556324", +>>>>>>> 62f6d18143 (fix(stock): show only batched items in batch item selector) "modified_by": "Administrator", "module": "Stock", "name": "Batch", @@ -236,4 +241,4 @@ "states": [], "title_field": "batch_id", "track_changes": 1 -} \ No newline at end of file +} From 71c7045b9830b92cef5fcd95bfa47ce1edc1125e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 16 Jun 2026 17:51:54 +0530 Subject: [PATCH 44/52] chore: removed redundant validation in SCR (cherry picked from commit 1b4487450c93137b9b4911e2daed82e3bf8be882) --- .../subcontracting_receipt.py | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index cd20986dc5a..8be5f453632 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -161,7 +161,6 @@ class SubcontractingReceipt(SubcontractingController): def on_submit(self): self.validate_closed_subcontracting_order() - self.validate_available_qty_for_consumption() self.validate_bom_required_qty() self.update_status_updater_args() self.update_prevdoc_status() @@ -506,32 +505,6 @@ class SubcontractingReceipt(SubcontractingController): _("Row #{0}: Accepted Warehouse and Rejected Warehouse cannot be same").format(item.idx) ) - def validate_available_qty_for_consumption(self): - if ( - frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") - == "BOM" - ): - return - - for item in self.get("supplied_items"): - precision = item.precision("consumed_qty") - if ( - item.available_qty_for_consumption - and flt(item.available_qty_for_consumption, precision) - flt(item.consumed_qty, precision) < 0 - ): - msg = _( - """Row {0}: Consumed Qty {1} {2} must be less than or equal to Available Qty For Consumption - {3} {4} in Consumed Items Table.""" - ).format( - item.idx, - flt(item.consumed_qty, precision), - item.stock_uom, - flt(item.available_qty_for_consumption, precision), - item.stock_uom, - ) - - frappe.throw(msg) - def validate_bom_required_qty(self): if ( frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") From df0c8ee21eccccbbafd724f8fcb48983e95b741c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 16 Jun 2026 17:27:27 +0530 Subject: [PATCH 45/52] fix: provision to recalculate valuation rate during reposting --- .../repost_item_valuation.js | 10 ++++ .../repost_item_valuation.json | 13 ++++- .../repost_item_valuation.py | 13 +++++ .../test_repost_item_valuation.py | 56 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 2 +- 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js index c514b25c8ef..0c46f2a41f5 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js @@ -90,6 +90,8 @@ frappe.ui.form.on("Repost Item Valuation", { }).addClass("btn-primary"); } + frm.trigger("show_update_valuation_field"); + frm.trigger("show_reposting_progress"); if (frm.doc.status === "Queued" && frm.doc.docstatus === 1) { @@ -97,6 +99,13 @@ frappe.ui.form.on("Repost Item Valuation", { } }, + show_update_valuation_field(frm) { + frm.toggle_display( + "recalculate_valuation_rate", + ["Purchase Receipt", "Purchase Invoice", "Stock Entry"].includes(frm.doc.voucher_type) + ); + }, + execute_reposting(frm) { frm.add_custom_button(__("Start Reposting"), () => { frappe.call({ @@ -157,6 +166,7 @@ frappe.ui.form.on("Repost Item Valuation", { voucher_type: function (frm) { frm.trigger("set_company_on_transaction"); + frm.trigger("show_update_valuation_field"); }, voucher_no: function (frm) { diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index 3affd1e4be9..55b1d696f89 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -20,7 +20,7 @@ "via_landed_cost_voucher", "allow_zero_rate", "recreate_stock_ledgers", - "amended_from", + "recalculate_valuation_rate", "error_section", "error_log", "reposting_info_section", @@ -31,6 +31,7 @@ "gl_reposting_index", "reposting_data_file", "vouchers_based_on_item_and_warehouse_section", + "amended_from", "total_vouchers", "column_break_yqwo", "vouchers_posted" @@ -237,13 +238,21 @@ "label": "Reposting Data File", "no_copy": 1, "read_only": 1 + }, + { + "default": "0", + "description": "Only works for Purchase Receipt, Purchase Invoice and Stock Entry", + "fieldname": "recalculate_valuation_rate", + "fieldtype": "Check", + "label": "Recalculate Valuation Rate", + "show_description_on_click": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-27 19:59:58.637964", + "modified": "2026-06-16 17:30:42.715321", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 1fc0f19a4d5..ef42da67668 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -45,6 +45,7 @@ class RepostItemValuation(Document): items_to_be_repost: DF.Code | None posting_date: DF.Date posting_time: DF.Time | None + recalculate_valuation_rate: DF.Check recreate_stock_ledgers: DF.Check reposting_data_file: DF.Attach | None reposting_reference: DF.Data | None @@ -303,6 +304,12 @@ class RepostItemValuation(Document): filters, ) + def _recalculate_valuation_rate(self): + doc = frappe.get_doc(self.voucher_type, self.voucher_no) + doc.update_valuation_rate() + for item in doc.items: + item.db_set("valuation_rate", item.valuation_rate) + def recreate_stock_ledger_entries(self): """Recreate Stock Ledger Entries for the transaction.""" if self.based_on == "Transaction" and self.recreate_stock_ledgers: @@ -331,6 +338,12 @@ def repost(doc): if not frappe.flags.in_test: frappe.db.commit() + if ( + doc.voucher_type in ["Purchase Receipt", "Purchase Invoice", "Stock Entry"] + and doc.recalculate_valuation_rate + ): + doc._recalculate_valuation_rate() + if doc.recreate_stock_ledgers: doc.recreate_stock_ledger_entries() 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 5cc3736fb0f..5291b2e4381 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 @@ -419,6 +419,62 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): self.assertRaises(frappe.ValidationError, riv.save) doc.cancel() + def test_recalculate_valuation_rate_for_purchase_receipt(self): + item = self.make_item().name + + # receive item at rate 100 + pr = make_purchase_receipt(item_code=item, qty=1, rate=100) + self.assertSLEs(pr, [{"incoming_rate": 100}]) + + # change the rate from 100 to 150 + pr.load_from_db() + pr.items[0].db_set( + { + "base_net_amount": 150, + "net_rate": 150, + } + ) + + # repost with recalculate valuation rate + riv = frappe.get_doc( + doctype="Repost Item Valuation", + based_on="Transaction", + voucher_type=pr.doctype, + voucher_no=pr.name, + recalculate_valuation_rate=1, + posting_date=pr.posting_date, + posting_time=pr.posting_time, + ) + riv.submit() + + # incoming rate after reposting should be 150 + self.assertSLEs(pr, [{"incoming_rate": 150}]) + + def test_recalculate_valuation_rate_for_stock_entry(self): + item = self.make_item().name + + # receive item at rate 100 + se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=1, rate=100) + self.assertSLEs(se, [{"incoming_rate": 100}]) + + # change the rate from 100 to 150 + se.items[0].db_set("basic_rate", 150) + + # repost with recalculate valuation rate + riv = frappe.get_doc( + doctype="Repost Item Valuation", + based_on="Transaction", + voucher_type=se.doctype, + voucher_no=se.name, + recalculate_valuation_rate=1, + posting_date=se.posting_date, + posting_time=se.posting_time, + ) + riv.submit() + + # incoming rate after reposting should be 150 + self.assertSLEs(se, [{"incoming_rate": 150}]) + def test_remove_attached_file(self): item_code = make_item("_Test Remove Attached File Item", properties={"is_stock_item": 1}) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a28b27aac5d..7ae0a2fda49 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1188,7 +1188,7 @@ class update_entries_after: sle.recalculate_rate or self.has_landed_cost_based_on_pi(sle) or (sle.voucher_type == "Stock Entry" and sle.actual_qty > 0 and is_repack_entry(sle.voucher_no)) - or (sle.voucher_type in ("Purchase Receipt", "Purchase Invoice")) + or (self.repost_doc and self.repost_doc.get("recalculate_valuation_rate")) ): rate = self.get_incoming_outgoing_rate_from_transaction(sle) From 1ffef19957b359619f5a29c6b674286b482c964d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 16 Jun 2026 20:22:09 +0530 Subject: [PATCH 46/52] fix(stock): enable quality inspection for all Stock Entry purposes Backport of #55830 to version-15. Allow creating a Quality Inspection from Stock Entries of any purpose (not just Manufacture). check_item_quality_inspection now returns the items for Stock Entry, and the inspection type is resolved as "Incoming" for Manufacture / Material Receipt and "Outgoing" otherwise. Ref: #70945 --- erpnext/controllers/stock_controller.py | 2 +- erpnext/public/js/controllers/transaction.js | 16 +- .../stock/doctype/stock_entry/stock_entry.js | 3 +- .../doctype/stock_entry/test_stock_entry.py | 321 ++++++++++++++++++ 4 files changed, 338 insertions(+), 4 deletions(-) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index d9ca43bab60..9e681bce814 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1694,7 +1694,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str inspection_fieldname = inspection_fieldname_map.get(doctype) if inspection_fieldname is None: - return [] + return items if doctype == "Stock Entry" else [] allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value( "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index e6eb9180eeb..5fce7783c38 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -362,8 +362,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, __("Create")); } - const inspection_type = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"].includes(this.frm.doc.doctype) - ? "Incoming" : "Outgoing"; + const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]; + const incoming_purposes = ["Manufacture", "Material Receipt"]; + const inspection_type = + incoming_doctypes.includes(this.frm.doc.doctype) || + (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose)) + ? "Incoming" + : "Outgoing"; let quality_inspection_field = this.frm.get_docfield("items", "quality_inspection"); quality_inspection_field.get_route_options_for_new_doc = function(row) { @@ -2474,6 +2479,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe ]; const me = this; + const incoming_doctypes = ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]; + const incoming_purposes = ["Manufacture", "Material Receipt"]; + const inspection_type = + incoming_doctypes.includes(this.frm.doc.doctype) || + (this.frm.doc.doctype === "Stock Entry" && incoming_purposes.includes(this.frm.doc.purpose)) + ? "Incoming" + : "Outgoing"; const dialog = new frappe.ui.Dialog({ title: __("Select Items for Quality Inspection"), size: "extra-large", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 3e17748bb8f..e2db1c7c98a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -201,10 +201,11 @@ frappe.ui.form.on("Stock Entry", { } let quality_inspection_field = frm.get_docfield("items", "quality_inspection"); + const incoming_purposes = ["Manufacture", "Material Receipt"]; quality_inspection_field.get_route_options_for_new_doc = function (row) { if (frm.is_new()) return {}; return { - inspection_type: "Incoming", + inspection_type: incoming_purposes.includes(frm.doc.purpose) ? "Incoming" : "Outgoing", reference_type: frm.doc.doctype, reference_name: frm.doc.name, child_row_reference: row.doc.name, diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 870091932b6..d0d60276a97 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -1094,6 +1094,327 @@ class TestStockEntry(FrappeTestCase): repack.insert() self.assertRaises(frappe.ValidationError, repack.submit) + def test_check_item_quality_inspection_returns_items_for_stock_entry(self): + from erpnext.controllers.stock_controller import check_item_quality_inspection + + items = [ + {"item_code": "_Test Item", "qty": 1}, + {"item_code": "_Test Item Home Desktop 100", "qty": 1}, + ] + + se_result = check_item_quality_inspection("Stock Entry", 0, items) + self.assertEqual(len(se_result), 2) + + # a doctype not in the inspection fieldname map and not a Stock Entry returns nothing + self.assertEqual(check_item_quality_inspection("Material Request", 0, items), []) + + @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"}) + def test_quality_inspection_across_stock_entry_purposes(self): + from erpnext.controllers.stock_controller import ( + QualityInspectionRejectedError, + QualityInspectionRequiredError, + check_item_quality_inspection, + ) + from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( + create_quality_inspection, + ) + + item_code = "_Test Item For QI Purposes" + if not frappe.db.exists("Item", item_code): + create_item(item_code, is_stock_item=1) + + s_wh = "Stores - _TC" + t_wh = "_Test Warehouse - _TC" + # stock the source warehouse for transfer / issue purposes + make_stock_entry(item_code=item_code, target=s_wh, qty=100, basic_rate=100) + + # purpose -> warehouses for the moved row; inward (with target) requires QI + purposes = { + "Material Receipt": {"to_warehouse": t_wh}, + "Material Transfer": {"from_warehouse": s_wh, "to_warehouse": t_wh}, + "Material Issue": {"from_warehouse": s_wh}, + } + + for purpose, warehouses in purposes.items(): + with self.subTest(purpose=purpose): + needs_qi = "to_warehouse" in warehouses + + se = make_stock_entry( + item_code=item_code, + qty=5, + basic_rate=100, + purpose=purpose, + inspection_required=True, + do_not_submit=True, + **warehouses, + ) + + # QI can be created from the Stock Entry for any purpose + allowed = check_item_quality_inspection("Stock Entry", 0, se.as_dict().get("items")) + self.assertTrue(any(row.get("item_code") == item_code for row in allowed)) + + if not needs_qi: + # outward-only entry: QI is not enforced + se.submit() + self.assertEqual(se.docstatus, 1) + continue + + # inward entry without QI must block submission + self.assertRaises(QualityInspectionRequiredError, se.submit) + + # a rejected QI must also block submission + se_rej = make_stock_entry( + item_code=item_code, + qty=5, + basic_rate=100, + purpose=purpose, + inspection_required=True, + do_not_submit=True, + **warehouses, + ) + create_quality_inspection( + reference_type="Stock Entry", + reference_name=se_rej.name, + item_code=item_code, + inspection_type="Incoming", + status="Rejected", + ) + se_rej.reload() + self.assertRaises(QualityInspectionRejectedError, se_rej.submit) + + # a submitted, accepted QI links itself to the inward row; submission then succeeds + se_ok = make_stock_entry( + item_code=item_code, + qty=5, + basic_rate=100, + purpose=purpose, + inspection_required=True, + do_not_submit=True, + **warehouses, + ) + create_quality_inspection( + reference_type="Stock Entry", + reference_name=se_ok.name, + item_code=item_code, + inspection_type="Incoming", + status="Accepted", + ) + se_ok.reload() + se_ok.submit() + self.assertEqual(se_ok.docstatus, 1) + + @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"}) + def test_quality_inspection_required_for_manufacture(self): + from erpnext.controllers.stock_controller import ( + QualityInspectionRejectedError, + QualityInspectionRequiredError, + ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_wo_stock_entry, + ) + from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( + create_quality_inspection, + ) + + wo = make_wo_order_test_record(qty=1) + make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100) + make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100 + ) + + # transfer raw materials to WIP (no inspection on the transfer) + transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1)) + for d in transfer.get("items"): + d.s_warehouse = "Stores - _TC" + transfer.insert() + transfer.submit() + + # manufacture with inspection required + mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1)) + mfg.inspection_required = 1 + mfg.insert() + self.assertRaises(QualityInspectionRequiredError, mfg.submit) + + # a rejected QI on the finished-good row must also block submission + qi = create_quality_inspection( + reference_type="Stock Entry", + reference_name=mfg.name, + item_code=wo.production_item, + inspection_type="Incoming", + status="Rejected", + ) + mfg.reload() + self.assertRaises(QualityInspectionRejectedError, mfg.submit) + + # accepting the QI then allows submission + frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted") + mfg.reload() + mfg.submit() + self.assertEqual(mfg.docstatus, 1) + + @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"}) + def test_quality_inspection_required_for_material_transfer_for_manufacture(self): + from erpnext.controllers.stock_controller import ( + QualityInspectionRejectedError, + QualityInspectionRequiredError, + ) + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_wo_stock_entry, + ) + from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( + create_quality_inspection, + ) + + wo = make_wo_order_test_record(qty=1) + make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100) + make_stock_entry( + item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=100 + ) + + transfer = frappe.get_doc(make_wo_stock_entry(wo.name, "Material Transfer for Manufacture", 1)) + for d in transfer.get("items"): + d.s_warehouse = "Stores - _TC" + transfer.inspection_required = 1 + transfer.insert() + self.assertRaises(QualityInspectionRequiredError, transfer.submit) + + # a rejected QI on any row moved into WIP must block submission; + # every raw-material row moved into WIP needs a QI + qis = [] + for item_code in {d.item_code for d in transfer.items if d.t_warehouse}: + qis.append( + create_quality_inspection( + reference_type="Stock Entry", + reference_name=transfer.name, + item_code=item_code, + inspection_type="Incoming", + status="Rejected", + ) + ) + transfer.reload() + self.assertRaises(QualityInspectionRejectedError, transfer.submit) + + # accepting every QI then allows submission + for qi in qis: + frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted") + transfer.reload() + transfer.submit() + self.assertEqual(transfer.docstatus, 1) + + def test_quality_inspection_required_for_send_to_subcontractor(self): + from erpnext.controllers.stock_controller import QualityInspectionRequiredError + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + from erpnext.controllers.tests.test_subcontracting_controller import ( + get_subcontracting_order, + make_service_item, + ) + from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( + create_quality_inspection, + ) + + make_service_item("Subcontracted Service Item 1") + sco = get_subcontracting_order( + service_items=[ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 1", + "qty": 10, + "rate": 500, + "fg_item": "_Test FG Item", + "fg_item_qty": 10, + } + ] + ) + make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=100, basic_rate=100) + make_stock_entry( + item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=100, basic_rate=100 + ) + + se = frappe.get_doc(make_rm_stock_entry(sco.name)) + se.from_warehouse = "_Test Warehouse - _TC" + se.to_warehouse = "_Test Warehouse - _TC" + se.stock_entry_type = "Send to Subcontractor" + se.inspection_required = 1 + se.insert() + self.assertRaises(QualityInspectionRequiredError, se.submit) + + for item_code in {row.item_code for row in se.items if row.t_warehouse}: + create_quality_inspection( + reference_type="Stock Entry", + reference_name=se.name, + item_code=item_code, + inspection_type="Outgoing", + status="Accepted", + ) + se.reload() + se.submit() + self.assertEqual(se.docstatus, 1) + + @change_settings("Stock Settings", {"action_if_quality_inspection_is_rejected": "Stop"}) + def test_quality_inspection_required_for_disassemble(self): + from erpnext.controllers.stock_controller import ( + QualityInspectionRejectedError, + QualityInspectionRequiredError, + ) + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_wo_stock_entry, + ) + from erpnext.stock.doctype.quality_inspection.test_quality_inspection import ( + create_quality_inspection, + ) + + source_warehouse = "Stores - _TC" + fg_item = make_item("Test Disassemble FG QI", {"is_stock_item": 1}).name + raw_materials = ["Test Disassemble RM QI 1", "Test Disassemble RM QI 2"] + for item in raw_materials: + make_item(item, {"is_stock_item": 1}) + make_stock_entry(item_code=item, target=source_warehouse, qty=5, basic_rate=100) + + make_bom(item=fg_item, source_warehouse=source_warehouse, raw_materials=raw_materials) + + wo = make_wo_order_test_record( + item=fg_item, qty=1, source_warehouse=source_warehouse, skip_transfer=1 + ) + + # manufacture the FG so there is something to disassemble + mfg = frappe.get_doc(make_wo_stock_entry(wo.name, "Manufacture", 1)) + for row in mfg.items: + if row.item_code in raw_materials: + row.s_warehouse = source_warehouse + mfg.submit() + + # disassemble with inspection required -> the component rows need a QI + dis = frappe.get_doc(make_wo_stock_entry(wo.name, "Disassemble", 1)) + dis.inspection_required = 1 + dis.insert() + self.assertRaises(QualityInspectionRequiredError, dis.submit) + + # a rejected QI on any disassembled component row must also block submission + qis = [] + for item_code in {row.item_code for row in dis.items if row.t_warehouse}: + qis.append( + create_quality_inspection( + reference_type="Stock Entry", + reference_name=dis.name, + item_code=item_code, + inspection_type="Outgoing", + status="Rejected", + ) + ) + dis.reload() + self.assertRaises(QualityInspectionRejectedError, dis.submit) + + # accepting every QI then allows submission + for qi in qis: + frappe.db.set_value("Quality Inspection", qi.name, "status", "Accepted") + dis.reload() + dis.submit() + self.assertEqual(dis.docstatus, 1) + def test_customer_provided_parts_se(self): create_item("CUST-0987", is_customer_provided_item=1, customer="_Test Customer", is_purchase_item=0) se = make_stock_entry( From ebdb23bdda585e3148b70bc3e92210d46f8b7215 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 16 Jun 2026 20:37:19 +0530 Subject: [PATCH 47/52] chore: resolve conflicts --- erpnext/stock/doctype/batch/batch.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 8fdf8ae5055..e318f113713 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -209,11 +209,7 @@ "image_field": "image", "links": [], "max_attachments": 5, -<<<<<<< HEAD - "modified": "2023-11-09 12:17:28.339975", -======= "modified": "2026-06-16 16:01:26.556324", ->>>>>>> 62f6d18143 (fix(stock): show only batched items in batch item selector) "modified_by": "Administrator", "module": "Stock", "name": "Batch", From 81e05fc1f61ff8ba0e6168830082edb3c725f902 Mon Sep 17 00:00:00 2001 From: nareshkannasln Date: Tue, 16 Jun 2026 16:08:52 +0530 Subject: [PATCH 48/52] fix: update system manager permissions (cherry picked from commit 501c8087cb468bbdeb1aa0576df1fb4ce7181f4f) --- erpnext/setup/doctype/driver/driver.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/driver/driver.json b/erpnext/setup/doctype/driver/driver.json index 2e994b5ff9c..6d9556b29e2 100644 --- a/erpnext/setup/doctype/driver/driver.json +++ b/erpnext/setup/doctype/driver/driver.json @@ -118,7 +118,7 @@ } ], "icon": "fa fa-user", - "modified": "2022-06-28 10:29:14.151380", + "modified": "2026-06-16 16:04:12.762960", "modified_by": "Administrator", "module": "Setup", "name": "Driver", @@ -173,6 +173,18 @@ "role": "Delivery Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 } ], "quick_entry": 1, From 64bbc019aded28862892a0e94b16301464404971 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:46:15 +0530 Subject: [PATCH 49/52] fix(accounts): removed whitelist on `get_balance_on` (backport #55956) (#55964) Co-authored-by: Diptanil Saha fix(accounts): removed whitelist on `get_balance_on` (#55956) --- erpnext/accounts/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index db820ca9154..53e366735e6 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -176,7 +176,6 @@ def validate_fiscal_year(date, fiscal_year, company, label="Date", doc=None): throw(_("{0} '{1}' not in Fiscal Year {2}").format(_(label), formatdate(date), fiscal_year)) -@frappe.whitelist() def get_balance_on( account=None, date=None, From 8a9461ff45f990a8f4740f3defb34a1ac7407a93 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:14:05 +0530 Subject: [PATCH 50/52] fix(company): replaced "this company" with company name on delete transactions dialog (backport #56021) (#56022) Co-authored-by: Diptanil Saha fix(company): replaced "this company" with company name on delete transactions dialog (#56021) --- erpnext/setup/doctype/company/company.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index f4de4bd2923..33a7ecc8714 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -204,12 +204,11 @@ frappe.ui.form.on("Company", { { fieldtype: "Data", fieldname: "company_name", - label: __('Please enter the company name "{0}" to confirm', [ - frappe.utils.escape_html(frm.doc.name), - ]), + label: __("Please enter the company name to confirm"), reqd: 1, description: __( - "Please make sure you really want to delete all the transactions for this company. Your master data will remain as it is. This action cannot be undone." + "Please make sure you really want to delete all the transactions for {0}. Your master data will remain as it is. This action cannot be undone.", + [frappe.utils.bold(frm.doc.name)] ), }, function (data) { @@ -229,7 +228,7 @@ frappe.ui.form.on("Company", { }, }); }, - __("Delete all the Transactions for this Company"), + __("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]), __("Delete") ); d.get_primary_btn().addClass("btn-danger"); From 5f5b2a6ae2f5047e9dbfb3e316ccdcbfbc68d9dd Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:30:22 +0000 Subject: [PATCH 51/52] fix: updated role based permission for terms and conditions doctype (backport #55674) (#55675) * fix: updated role based permission for terms and conditions doctype (#55674) (cherry picked from commit 0ba29611033481008f873d3e98ed6cfbd699a877) # Conflicts: # erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json * chore: resolve conflicts --------- Co-authored-by: Diptanil Saha --- .../terms_and_conditions.json | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json index 30598c9143b..c5fe12977a9 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json @@ -89,7 +89,7 @@ "icon": "icon-legal", "idx": 1, "links": [], - "modified": "2026-04-29 22:51:49.285298", + "modified": "2026-06-06 16:35:34.394675", "modified_by": "Administrator", "module": "Setup", "name": "Terms and Conditions", @@ -135,13 +135,32 @@ "print": 1, "read": 1, "report": 1, - "role": "Accounts User", + "role": "Accounts Manager", "share": 1, "write": 1 }, { "read": 1, "role": "Stock User" + }, + { + "role": "HR User", + "select": 1 + }, + { + "create": 1, + "read": 1, + "role": "HR Manager", + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 } ], "quick_entry": 1, From 8c7a313a38dfe38c9e35ca41e91389bcfaed2404 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:34:10 +0000 Subject: [PATCH 52/52] fix: Add authorization checks on internal functions (backport #55709) (backport #55726) (#55736) Co-authored-by: Ankush Menat fix: Add authorization checks on internal functions (backport #55709) (#55726) fix: Add authorization checks on internal functions (#55709) --- erpnext/accounts/doctype/account/account.py | 4 ++- .../bisect_accounting_statements.py | 2 +- .../doctype/pos_profile/pos_profile.py | 29 ------------------- .../process_payment_reconciliation.py | 3 ++ .../process_period_closing_voucher.py | 1 + .../process_statement_of_accounts.py | 1 + .../purchase_invoice/purchase_invoice.py | 1 + erpnext/accounts/utils.py | 1 + .../supplier_scorecard/supplier_scorecard.py | 1 + .../supplier_quotation_comparison.py | 3 +- erpnext/crm/doctype/lead/lead.py | 2 +- erpnext/projects/doctype/project/project.py | 2 +- erpnext/setup/doctype/employee/employee.py | 3 ++ .../doctype/delivery_trip/delivery_trip.py | 1 + .../stock_reposting_settings.py | 2 +- 15 files changed, 21 insertions(+), 35 deletions(-) diff --git a/erpnext/accounts/doctype/account/account.py b/erpnext/accounts/doctype/account/account.py index 43a7b21aabc..5dc862cc4cd 100644 --- a/erpnext/accounts/doctype/account/account.py +++ b/erpnext/accounts/doctype/account/account.py @@ -579,10 +579,12 @@ def update_account_number(name, account_name, account_number=None, from_descenda @frappe.whitelist() def merge_account(old, new): _ensure_idle_system() - # Validate properties before merging new_account = frappe.get_cached_doc("Account", new) old_account = frappe.get_cached_doc("Account", old) + new_account.check_permission("write") + old_account.check_permission("write") + if not new_account: throw(_("Account {0} does not exist").format(new)) diff --git a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py index da273b9f891..f0c6a15cade 100644 --- a/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py +++ b/erpnext/accounts/doctype/bisect_accounting_statements/bisect_accounting_statements.py @@ -121,7 +121,7 @@ class BisectAccountingStatements(Document): cur_node.save() - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def build_tree(self): frappe.db.delete("Bisect Nodes") diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 82c3103a039..5edaa2b89c3 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -308,32 +308,3 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): ) return pos_profile - - -@frappe.whitelist() -def set_default_profile(pos_profile, company): - modified = now() - user = frappe.session.user - - if pos_profile and company: - frappe.db.sql( - """ update `tabPOS Profile User` pfu, `tabPOS Profile` pf - set - pfu.default = 0, pf.modified = %s, pf.modified_by = %s - where - pfu.user = %s and pf.name = pfu.parent and pf.company = %s - and pfu.default = 1""", - (modified, user, user, company), - auto_commit=1, - ) - - frappe.db.sql( - """ update `tabPOS Profile User` pfu, `tabPOS Profile` pf - set - pfu.default = 1, pf.modified = %s, pf.modified_by = %s - where - pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s - """, - (modified, user, user, company, pos_profile), - auto_commit=1, - ) diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py index c4c6160fe52..02b7188ad6d 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -128,6 +128,7 @@ def is_job_running(job_name: str) -> bool: @frappe.whitelist() def pause_job_for_doc(docname: str | None = None): if docname: + frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused") log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname}) if log: @@ -142,6 +143,8 @@ def trigger_job_for_doc(docname: str | None = None): if not docname: return + frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) + if not frappe.db.get_single_value("Accounts Settings", "auto_reconcile_payments"): frappe.throw( _("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format( diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index e23751a8200..e6a330a4d14 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -89,6 +89,7 @@ class ProcessPeriodClosingVoucher(Document): @frappe.whitelist() def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: + frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") ppcvd = qb.DocType("Process Period Closing Voucher Detail") diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 26a45bbdc55..bf130db8e22 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -493,6 +493,7 @@ def download_statements(document_name): @frappe.whitelist() def send_emails(document_name, from_scheduler=False, posting_date=None): doc = frappe.get_doc("Process Statement Of Accounts", document_name) + doc.check_permission() report = get_report_pdf(doc, consolidated=False) if report: diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 0c5a73d6a01..f92252df2a7 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -2086,6 +2086,7 @@ def make_stock_entry(source_name, target_doc=None): 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) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 53e366735e6..05b4c0219a3 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -1386,6 +1386,7 @@ def update_cost_center(docname, cost_center_name, cost_center_number, company, m Renames the document by adding the number as a prefix to the current name and updates all transaction where it was present. """ + frappe.has_permission("Cost Center", "write", doc=docname, throw=True) validate_field_number("Cost Center", docname, cost_center_number, company, "cost_center_number") if cost_center_number: diff --git a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py index 5f0ae83dca8..d30f501d067 100644 --- a/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py +++ b/erpnext/buying/doctype/supplier_scorecard/supplier_scorecard.py @@ -201,6 +201,7 @@ def refresh_scorecards(): def make_all_scorecards(docname): sc = frappe.get_doc("Supplier Scorecard", docname) supplier = frappe.get_doc("Supplier", sc.supplier) + supplier.check_permission("write") start_date = getdate(supplier.creation) end_date = get_scorecard_date(sc.period, start_date) diff --git a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py index db93a3d7e79..7c11dda7225 100644 --- a/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py +++ b/erpnext/buying/report/supplier_quotation_comparison/supplier_quotation_comparison.py @@ -297,7 +297,8 @@ def get_message(): @frappe.whitelist() -def set_default_supplier(item_code, supplier, company): +def set_default_supplier(item_code: str, supplier: str, company: str): + frappe.has_permission("Item", "write", doc=item_code, throw=True) frappe.db.set_value( "Item Default", {"parent": item_code, "company": company}, diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index b92a0db08d9..94e99a612e8 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -519,7 +519,7 @@ def get_lead_with_phone_number(number): def add_lead_to_prospect(lead, prospect): prospect = frappe.get_doc("Prospect", prospect) prospect.append("leads", {"lead": lead}) - prospect.save(ignore_permissions=True) + prospect.save() carry_forward_communication_and_comments = frappe.db.get_single_value( "CRM Settings", "carry_forward_communication_and_comments" diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index f382118042a..a188015fb55 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -717,7 +717,7 @@ def set_project_status(project, status): frappe.throw(_("Status must be Cancelled or Completed")) project = frappe.get_doc("Project", project) - frappe.has_permission(doc=project, throw=True) + project.check_permission("write") for task in frappe.get_all("Task", dict(project=project.name)): frappe.db.set_value("Task", task.name, "status", status) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 7f6b78f4cb0..ead82ef8bf3 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -326,6 +326,9 @@ def deactivate_sales_person(status=None, employee=None): @frappe.whitelist() def create_user(employee, user=None, email=None): emp = frappe.get_doc("Employee", employee) + emp.check_permission("write") + if emp.user_id: + frappe.throw(_("Employee {0} already has a linked user").format(emp.name)) employee_name = emp.employee_name.split(" ") middle_name = last_name = "" diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index 9e5185dc0a0..e1e308c735e 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -375,6 +375,7 @@ def sanitize_address(address): @frappe.whitelist() def notify_customers(delivery_trip): delivery_trip = frappe.get_doc("Delivery Trip", delivery_trip) + delivery_trip.check_permission() context = delivery_trip.as_dict() diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index c2b3d81c5c7..c8c97116e20 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -46,7 +46,7 @@ class StockRepostingSettings(Document): if diff < 10: self.end_time = get_time_str(add_to_date(self.start_time, hours=10, as_datetime=True)) - @frappe.whitelist() + @frappe.whitelist(methods=["POST"]) def convert_to_item_wh_reposting(self): """Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled."""