From 16fbf8299f0a90e0faf5620019c28e28cea25ed2 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Wed, 27 May 2026 00:23:04 +0000 Subject: [PATCH 001/132] chore(release): Bumped to Version 15.109.0 # [15.109.0](https://github.com/frappe/erpnext/compare/v15.108.3...v15.109.0) (2026-05-27) ### Bug Fixes * consider batchwise valuation in stock ageing report (backport [#54919](https://github.com/frappe/erpnext/issues/54919)) ([#55229](https://github.com/frappe/erpnext/issues/55229)) ([418a7fb](https://github.com/frappe/erpnext/commit/418a7fb3015553884ecb739126793dfd809e00b5)) * consumed operation cost calculation (backport [#54858](https://github.com/frappe/erpnext/issues/54858)) ([#55132](https://github.com/frappe/erpnext/issues/55132)) ([46d5395](https://github.com/frappe/erpnext/commit/46d5395148747f8b4259a418fab98035bc4897b5)) * default use_for_shopping_cart to 0 in set_taxes ([960be3e](https://github.com/frappe/erpnext/commit/960be3e081959f6dff9537d5cab58b466020a5da)) * edit stock uom qty for purchase documents (backport [#55135](https://github.com/frappe/erpnext/issues/55135)) ([#55178](https://github.com/frappe/erpnext/issues/55178)) ([425e6c5](https://github.com/frappe/erpnext/commit/425e6c52f411c1bd69ea18fb3ce5e16cf04b6398)) * ERPNextTestSuite to change_settings ([76078a7](https://github.com/frappe/erpnext/commit/76078a7fb941ba2b820e1af9ac8c040f6a774654)) * faster range calculation on process period closing voucher ([bf27f2d](https://github.com/frappe/erpnext/commit/bf27f2d869417789a1c4d74c30c6ff3bc5a3d06e)) * fg valuation rate in repack entry when multiple FGs ([238f168](https://github.com/frappe/erpnext/commit/238f1685f1fcf33f6bef6a6a18b5c2b02ab27b11)) * **general-ledger:** show raw GL entries when categorize_by is empty (backport [#54816](https://github.com/frappe/erpnext/issues/54816)) ([#54829](https://github.com/frappe/erpnext/issues/54829)) ([b972b7c](https://github.com/frappe/erpnext/commit/b972b7c307cf01f754d02ee872b6f949908e9a68)) * import change_settings ([9d21199](https://github.com/frappe/erpnext/commit/9d211990c3a3782a938fb2ad7c24d2941783a73e)) * inclusive tax amount not considered while setting LCV from purchase invoice ([cba4c9f](https://github.com/frappe/erpnext/commit/cba4c9f0ee64f882657ed4bbea27f2dc5475f61a)) * incoming rate for legacy serial no ([6e6ef83](https://github.com/frappe/erpnext/commit/6e6ef83d60d3f42b32b2d578889e8bd5f0f2e62b)) * incorrect error message string in sales order (backport [#55090](https://github.com/frappe/erpnext/issues/55090)) ([#55094](https://github.com/frappe/erpnext/issues/55094)) ([04e28f9](https://github.com/frappe/erpnext/commit/04e28f95564e1579e25b25f87214a5916a069b87)) * invalid filter on item_group (backport [#55186](https://github.com/frappe/erpnext/issues/55186)) ([#55187](https://github.com/frappe/erpnext/issues/55187)) ([25739ae](https://github.com/frappe/erpnext/commit/25739ae217bee2411dd3d8564fd431f1c80ce6b5)) * merge conflicts ([59e9f51](https://github.com/frappe/erpnext/commit/59e9f5192c56d4bff7c10290e01fb30f8c3f301e)) * **payment_entry:** sync paid/received amounts for cross-currency entries (backport [#55270](https://github.com/frappe/erpnext/issues/55270)) ([#55271](https://github.com/frappe/erpnext/issues/55271)) ([d31a051](https://github.com/frappe/erpnext/commit/d31a051c747923d76559207e5642c02d706d7b28)) * prevent AttributeError in batch query filters (backport [#55257](https://github.com/frappe/erpnext/issues/55257)) ([#55278](https://github.com/frappe/erpnext/issues/55278)) ([4f89f3a](https://github.com/frappe/erpnext/commit/4f89f3a85646061ef39730eb658b9904f7b3b0b3)) * **project:** update customer and sales order as no copy ([9145760](https://github.com/frappe/erpnext/commit/914576040eb4d30b2f85aa4733268e8b9474315a)) * removed redundant code ([259f499](https://github.com/frappe/erpnext/commit/259f499e2550dcaa71907ce2f32965a817a6f376)) * set bin details when adding item using update items (backport [#55096](https://github.com/frappe/erpnext/issues/55096)) ([#55097](https://github.com/frappe/erpnext/issues/55097)) ([aa79247](https://github.com/frappe/erpnext/commit/aa79247c390d0f8506764035434f75f0ffb2ee94)) * single variant creation error (backport [#55286](https://github.com/frappe/erpnext/issues/55286)) ([#55288](https://github.com/frappe/erpnext/issues/55288)) ([937eb87](https://github.com/frappe/erpnext/commit/937eb87932e9fd6fdbacca33638e9012a4f15f77)) * **stock:** apply posting datetime filters while fetching available batches (backport [#54976](https://github.com/frappe/erpnext/issues/54976)) ([#55184](https://github.com/frappe/erpnext/issues/55184)) ([ff442cd](https://github.com/frappe/erpnext/commit/ff442cd8e7611d62d5f187376d3dc6160fd91992)) * **stock:** remove precision for valuation rate while creating sle (backport [#55249](https://github.com/frappe/erpnext/issues/55249)) ([#55259](https://github.com/frappe/erpnext/issues/55259)) ([8b241b4](https://github.com/frappe/erpnext/commit/8b241b45e2c6768ee33515eff4d537c5513158d1)) * **stock:** remove recalculate current qty function ([#55121](https://github.com/frappe/erpnext/issues/55121)) ([1c90c3b](https://github.com/frappe/erpnext/commit/1c90c3bbc210a1280fbedc1cac0f2061b97f9428)) * update import ([31c251d](https://github.com/frappe/erpnext/commit/31c251d9561d8149bc8d79e0e2c4a41bec18e7f6)) * use passed posting date in make_reverse_gl_entries ([4436585](https://github.com/frappe/erpnext/commit/4436585aa07a82ab3704d8091fd99482854aa854)) ### Features * add get_parent_supplier_groups using query builder ([6517ed7](https://github.com/frappe/erpnext/commit/6517ed72b4816b69f080259c6a7135bd82e30fe9)) ### Performance Improvements * skip delink_original_entry during cancellation when Immutable Ledger is enabled ([#55130](https://github.com/frappe/erpnext/issues/55130)) ([034e159](https://github.com/frappe/erpnext/commit/034e159ee4cb474d45fd76f44b7cfb95fea4460c)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index dbda07fc37e..36897538fc3 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.108.3" +__version__ = "15.109.0" def get_default_company(user=None): From bfdf1e43f9f2bf4e855190bce96b543c3f1afe01 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 17:35:20 +0530 Subject: [PATCH 002/132] fix: material transfer in transit issue (backport #55320) (backport #55324) (#55404) Co-authored-by: Rohit Waghchaure fix: material transfer in transit issue (backport #55320) (#55324) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index f0d7e10ad73..e6d6dab7426 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -3132,12 +3132,12 @@ class StockEntry(StockController): args = { "source_dt": "Stock Entry Detail", "target_field": "transferred_qty", - "target_ref_field": "qty", + "target_ref_field": "transfer_qty", "target_dt": "Stock Entry Detail", "join_field": "ste_detail", "target_parent_dt": "Stock Entry", "target_parent_field": "per_transferred", - "source_field": "qty", + "source_field": "transfer_qty", "percent_join_field": "against_stock_entry", } From 9e7b03173d0d16823e2261dbc1cfcc935ee47c0b Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Fri, 29 May 2026 12:07:01 +0000 Subject: [PATCH 003/132] chore(release): Bumped to Version 15.109.1 ## [15.109.1](https://github.com/frappe/erpnext/compare/v15.109.0...v15.109.1) (2026-05-29) ### Bug Fixes * material transfer in transit issue (backport [#55320](https://github.com/frappe/erpnext/issues/55320)) (backport [#55324](https://github.com/frappe/erpnext/issues/55324)) ([#55404](https://github.com/frappe/erpnext/issues/55404)) ([bfdf1e4](https://github.com/frappe/erpnext/commit/bfdf1e43f9f2bf4e855190bce96b543c3f1afe01)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 36897538fc3..19e050c3069 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.109.0" +__version__ = "15.109.1" def get_default_company(user=None): From c2063c4707eccbf67e1ec63b00587a4f8e6044b3 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 29 May 2026 19:17:34 +0530 Subject: [PATCH 004/132] fix: billing address does not belongs to the company error (cherry picked from commit 9df07b367a29eceae700614d5a98485cf48df080) (cherry picked from commit 5c392d61236176c367544dc336468b82b38eff5b) --- erpnext/public/js/controllers/buying.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 01d458d6b16..ff7218d78ae 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -176,14 +176,9 @@ erpnext.buying = { callback: (r) => { if (!r.message) return; - if (!this.frm.doc.billing_address) { - this.frm.set_value("billing_address", r.message.primary_address || ""); - } + this.frm.set_value("billing_address", r.message.primary_address || ""); - if ( - frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") && - !this.frm.doc.shipping_address - ) { + if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) { this.frm.set_value("shipping_address", r.message.shipping_address || ""); } }, From 1c5220b86f136e7f0faec020d057ada68e282c85 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Mon, 1 Jun 2026 06:08:30 +0000 Subject: [PATCH 005/132] chore(release): Bumped to Version 15.109.2 ## [15.109.2](https://github.com/frappe/erpnext/compare/v15.109.1...v15.109.2) (2026-06-01) ### Bug Fixes * billing address does not belongs to the company error ([c2063c4](https://github.com/frappe/erpnext/commit/c2063c4707eccbf67e1ec63b00587a4f8e6044b3)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 19e050c3069..6c6d7c3894d 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.109.1" +__version__ = "15.109.2" def get_default_company(user=None): From 4a6af25d11fd2d352a44005d7cf6bd277cf2a2e7 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 1 Jun 2026 12:13:49 +0530 Subject: [PATCH 006/132] fix: only consider non-opening balance for Balance sheet accounts (cherry picked from commit a2b83340461de901f097a99910de8e79052f8b67) --- .../process_period_closing_voucher.py | 3 +++ 1 file changed, 3 insertions(+) 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 e85ebd29e52..e23751a8200 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 @@ -559,6 +559,9 @@ def process_individual_date(docname: str, date, report_type, parentfield): if parentfield == "z_opening_balances": query = query.where(gle.is_opening.eq("Yes")) + else: + # Keep balances aligned with legacy PCV logic (non-opening transactions only) + query = query.where(gle.is_opening.eq("No")) query = query.groupby(gle.account) for dim in dimensions: From 94e15ae9efacd91ae417a3f7ac4dda74c5722c21 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 1 Jun 2026 12:17:44 +0530 Subject: [PATCH 007/132] refactor: tabbed view for process period closing voucher (cherry picked from commit 1960c816194d8f3ce91c1508b2db23430ef481c1) --- .../process_period_closing_voucher.json | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.json b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.json index a06a16f156c..0a30b6564b5 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.json +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "format:Process-PCV-{###}", "creation": "2025-09-25 15:44:03.534699", "doctype": "DocType", @@ -7,11 +8,13 @@ "field_order": [ "parent_pcv", "status", + "amended_from", + "section_normal_balances", "p_l_closing_balance", - "normal_balances", "bs_closing_balance", - "z_opening_balances", - "amended_from" + "normal_balances", + "section_opening_balances", + "z_opening_balances" ], "fields": [ { @@ -64,17 +67,27 @@ "fieldname": "bs_closing_balance", "fieldtype": "JSON", "label": "Balance Sheet Closing Balance" + }, + { + "fieldname": "section_normal_balances", + "fieldtype": "Tab Break", + "label": "Normal Balances" + }, + { + "fieldname": "section_opening_balances", + "fieldtype": "Tab Break", + "label": "Opening Balances" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-05 11:40:24.996403", + "modified": "2026-06-01 12:16:37.374412", "modified_by": "Administrator", "module": "Accounts", "name": "Process Period Closing Voucher", - "naming_rule": "Expression", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { From e314d0cfc5dfd5a22264d38abacaa2c77377f537 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 1 Jun 2026 12:51:04 +0530 Subject: [PATCH 008/132] refactor: color coded status in list view (cherry picked from commit cfeffbb3543ae05dd4fcd8f644c63e7a33144b72) --- .../process_period_closing_voucher_list.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher_list.js diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher_list.js b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher_list.js new file mode 100644 index 00000000000..4b117b8fbcf --- /dev/null +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher_list.js @@ -0,0 +1,17 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +// render +frappe.listview_settings["Process Period Closing Voucher"] = { + add_fields: ["status"], + get_indicator: function (doc) { + const status_colors = { + Queued: "blue", + Running: "orange", + Paused: "gray", + Completed: "green", + Cancelled: "red", + }; + return [__(doc.status), status_colors[doc.status], "status,=," + doc.status]; + }, +}; From 13eeddd1f6a691987018a3d6fe41c4b89ae135d7 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Mon, 1 Jun 2026 14:01:43 +0000 Subject: [PATCH 009/132] chore(release): Bumped to Version 15.109.3 ## [15.109.3](https://github.com/frappe/erpnext/compare/v15.109.2...v15.109.3) (2026-06-01) ### Bug Fixes * only consider non-opening balance for Balance sheet accounts ([4a6af25](https://github.com/frappe/erpnext/commit/4a6af25d11fd2d352a44005d7cf6bd277cf2a2e7)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 6c6d7c3894d..a74faef55c6 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.109.2" +__version__ = "15.109.3" def get_default_company(user=None): From 81ce5fbee90f78301e9d3f89d324af1c690911ea Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 1 Jun 2026 12:47:31 +0530 Subject: [PATCH 010/132] fix: use fiscal year instead of calendar year in accounting dashboard number cards (cherry picked from commit e8fff2fdadad91823a55db47fdd76a4be7528a5d) # Conflicts: # erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json # erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json # erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json # erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json --- .../total_incoming_bills/total_incoming_bills.json | 9 +++++++++ .../total_incoming_payment/total_incoming_payment.json | 9 +++++++++ .../total_outgoing_bills/total_outgoing_bills.json | 9 +++++++++ .../total_outgoing_payment/total_outgoing_payment.json | 9 +++++++++ 4 files changed, 36 insertions(+) diff --git a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json index 88c7cae3f69..a184869539e 100644 --- a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json +++ b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json @@ -4,14 +4,23 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Purchase Invoice", +<<<<<<< HEAD "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", +======= + "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", + "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Bills", +<<<<<<< HEAD "modified": "2024-11-20 19:08:37.043777", +======= + "modified": "2026-06-01 12:00:00.000000", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Bills", diff --git a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json index a53b222ed7d..6714b36dc15 100644 --- a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json +++ b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json @@ -4,14 +4,23 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", +<<<<<<< HEAD "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]", +======= + "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", + "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Payment", +<<<<<<< HEAD "modified": "2020-07-22 13:06:20.237689", +======= + "modified": "2026-06-01 12:00:00.000000", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Payment", diff --git a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json index 092defd94bd..da806362b3c 100644 --- a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json +++ b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json @@ -4,14 +4,23 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Sales Invoice", +<<<<<<< HEAD "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", +======= + "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", + "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Bills", +<<<<<<< HEAD "modified": "2020-07-22 13:07:19.633101", +======= + "modified": "2026-06-01 12:00:00.000000", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Bills", diff --git a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json index d60f30f7c9a..5230e4679c9 100644 --- a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json +++ b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json @@ -4,14 +4,23 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", +<<<<<<< HEAD "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]", +======= + "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", + "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Payment", +<<<<<<< HEAD "modified": "2020-07-22 12:49:34.942896", +======= + "modified": "2026-06-01 12:00:00.000000", +>>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Payment", From da1ccc2b622f8bb3ca766585ce08e25db2e3ff32 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 1 Jun 2026 13:13:29 +0530 Subject: [PATCH 011/132] fix: set a fallback value if no fiscal year set (cherry picked from commit c68918bc1823051575c5e0d0caacb229775ecae8) # Conflicts: # erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json # erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json # erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json # erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json --- .../total_incoming_bills/total_incoming_bills.json | 4 ++++ .../total_incoming_payment/total_incoming_payment.json | 4 ++++ .../total_outgoing_bills/total_outgoing_bills.json | 4 ++++ .../total_outgoing_payment/total_outgoing_payment.json | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json index a184869539e..5b545837905 100644 --- a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json +++ b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json @@ -4,11 +4,15 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Purchase Invoice", +<<<<<<< HEAD <<<<<<< HEAD "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", ======= "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", +======= + "dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", +>>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]", >>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", diff --git a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json index 6714b36dc15..4c13e4c1823 100644 --- a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json +++ b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json @@ -4,11 +4,15 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", +<<<<<<< HEAD <<<<<<< HEAD "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]", ======= "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", +======= + "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", +>>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]", >>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", diff --git a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json index da806362b3c..2f66ad50578 100644 --- a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json +++ b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json @@ -4,11 +4,15 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Sales Invoice", +<<<<<<< HEAD <<<<<<< HEAD "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", ======= "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", +======= + "dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", +>>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]", >>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", diff --git a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json index 5230e4679c9..6e583d8daf5 100644 --- a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json +++ b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json @@ -4,11 +4,15 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", +<<<<<<< HEAD <<<<<<< HEAD "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]", ======= "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", +======= + "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", +>>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]", >>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", From d6b2fb2f96a32018ee79cdeb89e39321bfb959f1 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Tue, 2 Jun 2026 16:55:43 +0000 Subject: [PATCH 012/132] chore(release): Bumped to Version 15.110.0 # [15.110.0](https://github.com/frappe/erpnext/compare/v15.109.3...v15.110.0) (2026-06-02) ### Bug Fixes * billing address does not belongs to the company error ([5c392d6](https://github.com/frappe/erpnext/commit/5c392d61236176c367544dc336468b82b38eff5b)) * **book_appointment:** when scheduling is disabled, block API endpoints (backport [#55455](https://github.com/frappe/erpnext/issues/55455)) ([#55456](https://github.com/frappe/erpnext/issues/55456)) ([2a12ae1](https://github.com/frappe/erpnext/commit/2a12ae1afe368c122a63d56a5153271f23fa311f)) * check perm for account (backport [#55479](https://github.com/frappe/erpnext/issues/55479)) ([#55482](https://github.com/frappe/erpnext/issues/55482)) ([1238aeb](https://github.com/frappe/erpnext/commit/1238aeb30a4a48e7e6830cfaf8d81db74ad4b0a3)) * **issue:** check permission before issue status modification (backport [#55458](https://github.com/frappe/erpnext/issues/55458)) ([#55459](https://github.com/frappe/erpnext/issues/55459)) ([338feb3](https://github.com/frappe/erpnext/commit/338feb31e1410bde26c887fe56d7fa6aeaaea273)) * **je:** preserve account on duplicate row when party row exists (backport [#55180](https://github.com/frappe/erpnext/issues/55180)) ([#55513](https://github.com/frappe/erpnext/issues/55513)) ([741216d](https://github.com/frappe/erpnext/commit/741216d3eb726d5429b12512e3aefaebf00e094c)) * **manufacturing:** allow to edit batch size while creating a work order ([#55332](https://github.com/frappe/erpnext/issues/55332)) ([41bf2f3](https://github.com/frappe/erpnext/commit/41bf2f32fd223b1074988bea0a5dcecfaee651d7)) * material transfer in transit issue (backport [#55320](https://github.com/frappe/erpnext/issues/55320)) ([#55324](https://github.com/frappe/erpnext/issues/55324)) ([067c23f](https://github.com/frappe/erpnext/commit/067c23f20e5541eacb37a188be379121c2e7ddfb)) * new bom version should not recalculate operations through routing (backport [#55370](https://github.com/frappe/erpnext/issues/55370)) ([#55371](https://github.com/frappe/erpnext/issues/55371)) ([4669ff2](https://github.com/frappe/erpnext/commit/4669ff295f6beab550fd05980bc6927297ee36a7)) * pick correct name when creating user from RFQ (backport [#55468](https://github.com/frappe/erpnext/issues/55468)) ([#55471](https://github.com/frappe/erpnext/issues/55471)) ([e429e60](https://github.com/frappe/erpnext/commit/e429e608c2ab9451bf307b46776563dc8a3d2a44)) * **pos:** escape html output in pos page templates (backport [#55527](https://github.com/frappe/erpnext/issues/55527)) ([#55528](https://github.com/frappe/erpnext/issues/55528)) ([689a3f5](https://github.com/frappe/erpnext/commit/689a3f50ae114c2e9751fe9dba2184b40d6674d9)) * **pos:** escape item data on pos item selector (backport [#55503](https://github.com/frappe/erpnext/issues/55503)) ([#55523](https://github.com/frappe/erpnext/issues/55523)) ([96bd97d](https://github.com/frappe/erpnext/commit/96bd97dd6d90b6d0c2186fb83d018621197b57da)) * **pos:** preserve contacts and enforce permissions in set_customer_info (backport [#55463](https://github.com/frappe/erpnext/issues/55463)) ([#55465](https://github.com/frappe/erpnext/issues/55465)) ([0353262](https://github.com/frappe/erpnext/commit/03532624b847598e6b27e7fd222f38bb748bbe5f)) * **ppr:** make default_advance_account optional ([aa94c3f](https://github.com/frappe/erpnext/commit/aa94c3ff22b6f1cb92b1b49c7bbd3b441228f707)) * **quotation:** made customer contact column visible (backport [#55433](https://github.com/frappe/erpnext/issues/55433)) ([#55434](https://github.com/frappe/erpnext/issues/55434)) ([a2d924c](https://github.com/frappe/erpnext/commit/a2d924c48f4192eccba4be010b04dd6f68b3ea44)) * **regional:** Japanese CT Rate (backport [#54998](https://github.com/frappe/erpnext/issues/54998)) ([#55437](https://github.com/frappe/erpnext/issues/55437)) ([2a52ea6](https://github.com/frappe/erpnext/commit/2a52ea6850e84e45338456d93c226c8085962746)) * replace get_query with get_list for permission-aware queries in v15 ([ad511b8](https://github.com/frappe/erpnext/commit/ad511b80c05b8f2efddc1b03494b9a473808f7b9)) * stock reco for legacy serial nos ([93dcba4](https://github.com/frappe/erpnext/commit/93dcba40ec3819a175c9c088d885e37159f848d8)) * **stock:** add warning message to notify the user to configure the inspection ([42e2fd5](https://github.com/frappe/erpnext/commit/42e2fd5fc98c4881aadfa9959a0fb725e287109c)) * **stock:** allow to create quality inspection after purchase/delivery ([10664b7](https://github.com/frappe/erpnext/commit/10664b7b9539f822e126014c9e8dae418ce818d6)) * **stock:** change qb to qb get_query to fix filter issues (backport [#55443](https://github.com/frappe/erpnext/issues/55443)) ([#55444](https://github.com/frappe/erpnext/issues/55444)) ([75d00ef](https://github.com/frappe/erpnext/commit/75d00ef173c24d4230d20f8311325520277f90e4)) * **stock:** change valuation rate column label in stock ledger entry/report (backport [#55323](https://github.com/frappe/erpnext/issues/55323)) ([#55393](https://github.com/frappe/erpnext/issues/55393)) ([94fd15e](https://github.com/frappe/erpnext/commit/94fd15e550bc2a058463c9002a5ba043b7c677cf)) * **stock:** get_actual_qty during cancellations (backport [#55388](https://github.com/frappe/erpnext/issues/55388)) ([#55391](https://github.com/frappe/erpnext/issues/55391)) ([ad6e3a4](https://github.com/frappe/erpnext/commit/ad6e3a45d224b420a7b3ff4ed20c72cb40783e7f)) * update default_advance_account type ([7200c22](https://github.com/frappe/erpnext/commit/7200c228900b2033a681a7f3e65417c98626b6b7)) * use get_query instead of get_all for data fetching ([264433b](https://github.com/frappe/erpnext/commit/264433b23d61ffa41a75adcc9c554941e1de4e29)) ### Features * **payment-entry:** warn user before cancelling reconciled payment entry ([87c6ad4](https://github.com/frappe/erpnext/commit/87c6ad4f85f1401fdf5cd804c4b130398d4ad80b)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index a74faef55c6..4be8b579505 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.109.3" +__version__ = "15.110.0" def get_default_company(user=None): From 45b232d369eb1624383986d7047d5625c7e4f08f Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 31 May 2026 18:42:26 +0530 Subject: [PATCH 013/132] fix: use new_doc with field allowlist in CRM integration endpoints (cherry picked from commit e460e83516b48664c1be09a93d83a6d80fe90f01) --- erpnext/crm/frappe_crm_api.py | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 86521586b28..586ebe91eed 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -32,20 +32,16 @@ def create_custom_fields_for_frappe_crm(): @frappe.whitelist() def create_prospect_against_crm_deal(): doc = frappe.form_dict - prospect = frappe.get_doc( - { - "doctype": "Prospect", - "company_name": doc.organization or doc.lead_name, - "no_of_employees": doc.no_of_employees, - "prospect_owner": doc.deal_owner, - "company": doc.erpnext_company, - "crm_deal": doc.crm_deal, - "territory": doc.territory, - "industry": doc.industry, - "website": doc.website, - "annual_revenue": doc.annual_revenue, - } - ) + prospect = frappe.new_doc("Prospect") + prospect.company_name = doc.organization or doc.lead_name + prospect.no_of_employees = doc.no_of_employees + prospect.prospect_owner = doc.deal_owner + prospect.company = doc.erpnext_company + prospect.crm_deal = doc.crm_deal + prospect.territory = doc.territory + prospect.industry = doc.industry + prospect.website = doc.website + prospect.annual_revenue = doc.annual_revenue try: prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name}) @@ -151,6 +147,18 @@ def contact_exists(email, mobile_no): return False +CUSTOMER_ALLOWED_FIELDS = { + "customer_name", + "customer_group", + "customer_type", + "territory", + "default_currency", + "industry", + "website", + "crm_deal", +} + + @frappe.whitelist() def create_customer(customer_data=None): if not customer_data: @@ -159,9 +167,11 @@ def create_customer(customer_data=None): try: customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")}) if not customer_name: - customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert( - ignore_permissions=True - ) + customer = frappe.new_doc("Customer") + for field in CUSTOMER_ALLOWED_FIELDS: + if customer_data.get(field) is not None: + customer.set(field, customer_data.get(field)) + customer.insert(ignore_permissions=True) customer_name = customer.name contacts = json.loads(customer_data.get("contacts")) From 2d1c0dcb53e4ff1069ceb39f67063cb954f578dd Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sun, 31 May 2026 04:40:02 +0530 Subject: [PATCH 014/132] fix: add permission checks in accounts whitelisted methods (cherry picked from commit 5dbf3fdde0e11fa482a42bfa36559becdc814c6a) # Conflicts: # erpnext/accounts/doctype/payment_entry/payment_entry.py --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 6 +++++- erpnext/accounts/utils.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 34120776681..5a2c90a89d5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2279,6 +2279,9 @@ def get_outstanding_reference_documents(args, validate=False): if args.get("party_type") == "Member": return + if args.get("party_type") and args.get("party"): + frappe.has_permission(args["party_type"], "read", args["party"], throw=True) + if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"): args["get_outstanding_invoices"] = True @@ -2788,7 +2791,8 @@ def get_reference_details( ): total_amount = outstanding_amount = exchange_rate = account = None - ref_doc = frappe.get_doc(reference_doctype, reference_name) + frappe.has_permission(reference_doctype, "read", reference_name, throw=True) + ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name) company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company) # Only applies for Reverse Payment Entries diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 7fff2f156c4..db820ca9154 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -278,6 +278,7 @@ def get_balance_on( ) if party_type and party: + frappe.has_permission(party_type, "read", party, throw=True) cond.append( f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """ ) From 07b61113af40c5a247b1cb51c6bdb4afc8e83eeb Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 8 Jun 2026 13:07:26 +0530 Subject: [PATCH 015/132] fix: do not allow to make changes in SABB after submit (cherry picked from commit e36426e235790ee0f55b7d7d88d7e1f0e7937982) # Conflicts: # erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py --- .../serial_and_batch_bundle.py | 9 ++++ .../test_serial_and_batch_bundle.py | 54 +++++++++++++++++++ .../stock_reconciliation.py | 31 ++++------- 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 1ac6afd15b1..39e5f1425f0 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2169,7 +2169,16 @@ def get_type_of_transaction(parent_doc, child_row): def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, warehouse=None) -> object: + frappe.has_permission("Serial and Batch Bundle", "write", throw=True) doc = frappe.get_doc("Serial and Batch Bundle", bundle) + + if doc.docstatus == 1: + doc.throw_error_message( + _("Serial and Batch Bundle {0} is submitted and its entries cannot be modified.").format( + frappe.bold(bundle) + ) + ) + doc.voucher_detail_no = child_row.name doc.posting_date = parent_doc.posting_date doc.posting_time = parent_doc.posting_time diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 5e33852badc..2c184cbb876 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -734,6 +734,60 @@ class TestSerialandBatchBundle(FrappeTestCase): docstatus = frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus") self.assertEqual(docstatus, 2) + def test_submitted_bundle_entries_cannot_be_mutated(self): + # A submitted Serial and Batch Bundle is the immutable source of truth for the stock + # ledger, live batch availability and repost/valuation replay. update_serial_batch_no_ledgers + # (which the whitelisted add_serial_batch_ledgers delegates to for an existing bundle) must + # refuse to rebuild -- and thereby inflate -- the quantities of an already submitted bundle. + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + update_serial_batch_no_ledgers, + ) + + item_code = make_item( + properties={ + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TAMPER-SBB-.#####", + } + ).name + + se = make_stock_entry( + item_code=item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + ) + + bundle = se.items[0].serial_and_batch_bundle + self.assertEqual(frappe.db.get_value("Serial and Batch Bundle", bundle, "docstatus"), 1) + + original = frappe.db.get_value( + "Serial and Batch Entry", {"parent": bundle}, ["name", "batch_no", "qty"], as_dict=True + ) + self.assertEqual(original.qty, 10) + + # Attempt to forge the submitted bundle: keep the same batch but inflate qty. The guard + # fires immediately after the bundle is loaded (docstatus check), so child_row / parent_doc + # only need the minimal fields the function reads. + tampered_entries = [{"batch_no": original.batch_no, "qty": 1000}] + child_row = frappe._dict({"name": se.items[0].name}) + parent_doc = frappe._dict({"posting_date": today(), "posting_time": nowtime()}) + + self.assertRaises( + frappe.ValidationError, + update_serial_batch_no_ledgers, + bundle, + tampered_entries, + child_row, + parent_doc, + ) + + # The on-disk quantity must be untouched by the rejected mutation attempt. + self.assertEqual( + frappe.db.get_value("Serial and Batch Entry", original.name, "qty"), + 10, + ) + def test_batch_duplicate_entry(self): item_code = make_item(properties={"has_batch_no": 1}).name diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 085383f4abf..6f9f42a64e8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -9,14 +9,19 @@ from frappe.utils import add_to_date, cint, cstr, flt, get_datetime, now import erpnext from erpnext.accounts.utils import get_company_default -from erpnext.controllers.stock_controller import StockController, create_repost_item_valuation_entry +from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_available_serial_nos, ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +<<<<<<< HEAD from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_stock_balance +======= +from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem +from erpnext.stock.utils import get_incoming_rate, get_stock_balance +>>>>>>> e36426e235 (fix: do not allow to make changes in SABB after submit) class OpeningEntryAccountError(frappe.ValidationError): @@ -842,22 +847,6 @@ class StockReconciliation(StockController): sl_entries.append(args) - def update_valuation_rate_for_serial_no(self): - for d in self.items: - if not d.serial_no: - continue - - serial_nos = get_serial_nos(d.serial_no) - self.update_valuation_rate_for_serial_nos(d, serial_nos) - - def update_valuation_rate_for_serial_nos(self, row, serial_nos): - valuation_rate = row.valuation_rate if self.docstatus == 1 else row.current_valuation_rate - if valuation_rate is None: - return - - for d in serial_nos: - frappe.db.set_value("Serial No", d, "purchase_rate", valuation_rate) - def get_sle_for_items(self, row, serial_nos=None, current_bundle=True): """Insert Stock Ledger Entries""" @@ -1011,11 +1000,6 @@ class StockReconciliation(StockController): d.quantity_difference = flt(d.qty) - flt(d.current_qty) d.amount_difference = flt(d.amount) - flt(d.current_amount) - def get_items_for(self, warehouse): - self.items = [] - for item in get_items(warehouse, self.posting_date, self.posting_time, self.company): - self.append("items", item) - def submit(self): if len(self.items) > 100: msgprint( @@ -1038,6 +1022,7 @@ class StockReconciliation(StockController): else: self._cancel() +<<<<<<< HEAD def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): if row.current_qty == 0: return @@ -1177,6 +1162,8 @@ def get_batch_qty_for_stock_reco( return flt(qty) +======= +>>>>>>> e36426e235 (fix: do not allow to make changes in SABB after submit) @frappe.whitelist() def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False): From dc1fb3f80479aa4059076f64fa6c5981f2486aa1 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 8 Jun 2026 15:28:44 +0530 Subject: [PATCH 016/132] chore: fix conflicts Removed unused methods related to stock ledger entries and negative stock handling. --- .../stock_reconciliation.py | 147 ------------------ 1 file changed, 147 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 6f9f42a64e8..b3e330a0a85 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -16,12 +16,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle impor get_available_serial_nos, ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos -<<<<<<< HEAD -from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_stock_balance -======= -from erpnext.stock.doctype.stock_reconciliation_item.stock_reconciliation_item import StockReconciliationItem from erpnext.stock.utils import get_incoming_rate, get_stock_balance ->>>>>>> e36426e235 (fix: do not allow to make changes in SABB after submit) class OpeningEntryAccountError(frappe.ValidationError): @@ -1022,148 +1017,6 @@ class StockReconciliation(StockController): else: self._cancel() -<<<<<<< HEAD - def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): - if row.current_qty == 0: - return - - new_sle = frappe.get_doc(self.get_sle_for_items(row)) - new_sle.actual_qty = row.current_qty * -1 - new_sle.valuation_rate = row.current_valuation_rate - new_sle.serial_and_batch_bundle = row.current_serial_and_batch_bundle - new_sle.flags.ignore_permissions = 1 - new_sle.submit() - - creation = add_to_date(sle_creation, seconds=-1) - new_sle.db_set("creation", creation) - - if not frappe.db.exists( - "Repost Item Valuation", - {"item": row.item_code, "warehouse": row.warehouse, "docstatus": 1, "status": "Queued"}, - ): - create_repost_item_valuation_entry( - { - "based_on": "Item and Warehouse", - "item_code": row.item_code, - "warehouse": row.warehouse, - "company": self.company, - "allow_negative_stock": 1, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - } - ) - - def has_negative_stock_allowed(self): - allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - if allow_negative_stock: - return True - - if any( - ((d.serial_and_batch_bundle or d.batch_no) and flt(d.qty) == flt(d.current_qty)) - for d in self.items - ): - allow_negative_stock = True - - return allow_negative_stock - - def get_current_qty_for_serial_or_batch(self, row, sle_creation): - doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle) - current_qty = 0.0 - if doc.has_serial_no: - current_qty = self.get_current_qty_for_serial_nos(doc, sle_creation) - elif doc.has_batch_no: - current_qty = self.get_current_qty_for_batch_nos(doc, sle_creation) - - return abs(current_qty) - - def get_current_qty_for_serial_nos(self, doc, sle_creation): - serial_nos_details = get_available_serial_nos( - frappe._dict( - { - "item_code": doc.item_code, - "warehouse": doc.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "creation": sle_creation, - "voucher_no": self.name, - "ignore_warehouse": 1, - } - ) - ) - - if not serial_nos_details: - return 0.0 - - doc.delete_serial_batch_entries() - current_qty = 0.0 - for serial_no_row in serial_nos_details: - current_qty += 1 - doc.append( - "entries", - { - "serial_no": serial_no_row.serial_no, - "qty": -1, - "warehouse": doc.warehouse, - "batch_no": serial_no_row.batch_no, - }, - ) - - doc.set_incoming_rate(save=True) - doc.calculate_qty_and_amount(save=True) - doc.db_update_all() - - return current_qty - - def get_current_qty_for_batch_nos(self, doc, sle_creation): - current_qty = 0.0 - precision = doc.entries[0].precision("qty") - for d in doc.entries: - qty = ( - get_batch_qty( - d.batch_no, - doc.warehouse, - creation=sle_creation, - posting_date=doc.posting_date, - posting_time=doc.posting_time, - ignore_voucher_nos=[doc.voucher_no], - for_stock_levels=True, - consider_negative_batches=True, - do_not_check_future_batches=True, - ) - or 0 - ) * -1 - - if flt(d.qty, precision) != flt(qty, precision): - d.db_set("qty", qty) - - current_qty += qty - - return current_qty - - -def get_batch_qty_for_stock_reco( - item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation -): - qty = ( - get_batch_qty( - batch_no, - warehouse, - item_code, - creation=sle_creation, - posting_date=posting_date, - posting_time=posting_time, - ignore_voucher_nos=[voucher_no], - for_stock_levels=True, - consider_negative_batches=True, - do_not_check_future_batches=True, - ) - or 0 - ) - - return flt(qty) - -======= ->>>>>>> e36426e235 (fix: do not allow to make changes in SABB after submit) @frappe.whitelist() def get_items(warehouse, posting_date, posting_time, company, item_code=None, ignore_empty_stock=False): From 09453f883ba8ea1a355d044d3f416f8925d663df Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:42:09 +0530 Subject: [PATCH 017/132] fix: remove item name from update items dialog item code column (backport #55718) (#55722) Co-authored-by: Mihir Kandoi Co-authored-by: Abdullah fix: remove item name from update items dialog item code column (#55718) --- erpnext/public/js/utils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 293f8e1324e..3ae0b3d1835 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -651,6 +651,7 @@ erpnext.utils.update_child_items = function (opts) { read_only: 0, disabled: 0, label: __("Item Code"), + formatter: (value) => value, get_query: function () { let filters; if (frm.doc.doctype == "Sales Order") { From ba19a245266f280dceb7e9d550d4b7d76e27f26c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 8 Jun 2026 17:42:04 +0530 Subject: [PATCH 018/132] fix: validate fg and materials qty in the disassemble entry (cherry picked from commit 4453c1072a8a98c67ed425b5e5ae5d27d90d1c21) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py # erpnext/stock/doctype/stock_entry/test_stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 104 ++++++++++++++++++ .../doctype/stock_entry/test_stock_entry.py | 85 ++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b52945d8928..cc0813ffd9e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -80,7 +80,17 @@ from erpnext.controllers.stock_controller import StockController form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"} +<<<<<<< HEAD class StockEntry(StockController): +======= +def _qty_tolerance(precision: int) -> float: + """One unit at the column's precision -- absorbs float rounding without letting a real + (whole-unit) quantity divergence slip through.""" + return 1.0 / (10**precision) + + +class StockEntry(StockController, SubcontractingInwardController): +>>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry) # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -228,6 +238,13 @@ class StockEntry(StockController): self.validate_warehouse() self.validate_with_material_request() + + # Disassembly rows are fully derived from the source manufacture entry / work order; + # verify the posted stock quantities have not been tampered with (raw-material minting). + # Must run after set_transfer_qty() so row.transfer_qty reflects qty * conversion_factor. + if self.purpose == "Disassemble": + self.validate_disassembly_quantities() + self.validate_batch() self.validate_inspection() self.validate_fg_completed_qty() @@ -931,6 +948,93 @@ class StockEntry(StockController): title=_("Excess Disassembly"), ) + def validate_disassembly_quantities(self): + self.validate_finished_good_consumption() + self.validate_materials_against_source() + + def validate_finished_good_consumption(self): + """The finished good consumed (in stock UOM) must equal the quantity to disassemble.""" + precision = frappe.get_precision("Stock Entry Detail", "transfer_qty") + tolerance = _qty_tolerance(precision) + + fg_stock_qty = sum(flt(row.transfer_qty) for row in self.items if row.is_finished_item) + fg_completed_qty = flt(self.fg_completed_qty) + + if abs(flt(fg_stock_qty, precision) - flt(fg_completed_qty, precision)) > tolerance: + frappe.throw( + _( + "Finished good quantity being consumed ({0} in stock UOM) must equal the quantity " + "to disassemble ({1}). Do not change the UOM, conversion factor or quantity of the " + "finished good row." + ).format(flt(fg_stock_qty, precision), flt(fg_completed_qty, precision)), + title=_("Invalid Disassembly Quantity"), + ) + + def validate_materials_against_source(self): + """Every non-finished-good row's posted stock qty must equal the source qty x scale.""" + scale_factor = self._get_disassembly_scale_factor() + if not scale_factor: + # Standalone BOM disassembly: no source entry to scale against. The finished-good + # invariant above still applies; raw-material amounts come from the BOM. + return + + source_rows = self.get_items_from_manufacture_stock_entry() + source_by_name = {row.name: row for row in source_rows if row.get("name")} + source_by_item = defaultdict(float) + for row in source_rows: + source_by_item[row.item_code] += flt(row.transfer_qty) + + precision = frappe.get_precision("Stock Entry Detail", "transfer_qty") + tolerance = _qty_tolerance(precision) + + for row in self.items: + if row.is_finished_item: + continue # covered by validate_finished_good_consumption + + if row.ste_detail and row.ste_detail in source_by_name: + expected = flt(source_by_name[row.ste_detail].transfer_qty) * scale_factor + elif row.item_code in source_by_item: + expected = source_by_item[row.item_code] * scale_factor + else: + frappe.throw( + _( + "Row #{0}: Item {1} is not part of the source manufacture entry and cannot be " + "added to this disassembly." + ).format(row.idx, frappe.bold(row.item_code)), + title=_("Invalid Disassembly Item"), + ) + + if abs(flt(row.transfer_qty, precision) - flt(expected, precision)) > tolerance: + frappe.throw( + _( + "Row #{0}: Item {1} quantity ({2} in stock UOM) does not match the quantity " + "derived from the source ({3}). Do not change the UOM, conversion factor or " + "quantity of disassembly rows." + ).format( + row.idx, + frappe.bold(row.item_code), + flt(row.transfer_qty, precision), + flt(expected, precision), + ), + title=_("Invalid Disassembly Quantity"), + ) + + def _get_disassembly_scale_factor(self) -> float: + disassemble_qty = flt(self.fg_completed_qty) + if self.source_stock_entry: + source_fg_qty = flt( + frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty") + ) + elif self.work_order: + source_fg_qty = flt(frappe.db.get_value("Work Order", self.work_order, "produced_qty")) + else: + return 0.0 + + if not source_fg_qty: + return 0.0 + + return disassemble_qty / source_fg_qty + def check_if_operations_completed(self): """Check if Time Sheets are completed against before manufacturing to capture operating costs.""" prod_order = frappe.get_doc("Work Order", self.work_order) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index a82a1266651..19b435f8481 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2244,6 +2244,91 @@ class TestStockEntry(FrappeTestCase): se.save() se.submit() +<<<<<<< HEAD +======= + def test_disassemble_blocks_finished_good_qty_tampering(self): + # A disassembly consuming N finished goods must consume exactly N (in stock UOM). + # Switching the finished-good row to a larger UOM with a tiny conversion_factor previously + # let a user consume ~0 finished goods while still producing the full raw materials -- + # minting inventory. The quantity invariant must reject this. + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + fg_item = make_item("_Disassemble Mint FG", properties={"is_stock_item": 1}).name + rm_item1 = make_item("_Disassemble Mint RM1", properties={"is_stock_item": 1}).name + rm_item2 = make_item("_Disassemble Mint RM2", properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + + # Give the finished good a non-stock UOM. When uom == stock_uom the system resets the + # conversion_factor to 1, so the tamper is only possible (and worth guarding) on a + # non-stock UOM, where the user-supplied conversion_factor is preserved. + if not frappe.db.get_value("UOM Conversion Detail", {"parent": fg_item, "uom": "Box"}): + item_doc = frappe.get_doc("Item", fg_item) + item_doc.append("uoms", {"uom": "Box", "conversion_factor": 0.01}) + item_doc.save(ignore_permissions=True) + + make_stock_entry(item_code=fg_item, target=warehouse, qty=100, purpose="Material Receipt") + bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name + + se = make_stock_entry(item_code=fg_item, qty=100, purpose="Disassemble", do_not_save=True) + se.from_bom = 1 + se.use_multi_level_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 100 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + se.get_items() + + # Tamper the finished-good row: a tiny conversion factor on the larger UOM means only + # 100 * 0.01 = 1 unit is actually consumed, while raw materials are still produced at full + # quantity. The finished-good consumption invariant must reject the save. + fg_row = next(d for d in se.items if d.is_finished_item) + fg_row.uom = "Box" + fg_row.conversion_factor = 0.01 + + self.assertRaises(frappe.ValidationError, se.save) + + @ERPNextTestSuite.change_settings( + "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} + ) + def test_sample_retention_stock_entry(self): + from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse + + warehouse = "_Test Warehouse - _TC" + retain_sample_item = make_item( + "Retain Sample Item", + properties={ + "is_stock_item": 1, + "retain_sample": 1, + "sample_quantity": 2, + "has_batch_no": 1, + "has_serial_no": 1, + "create_new_batch": 1, + "batch_number_series": "SAMPLE-RET-.#####", + "serial_no_series": "SAMPLE-RET-SN-.#####", + }, + ) + material_receipt = make_stock_entry( + item_code=retain_sample_item.item_code, target=warehouse, qty=10, purpose="Material Receipt" + ) + + source_sabb = frappe.get_doc( + "Serial and Batch Bundle", material_receipt.items[0].serial_and_batch_bundle + ) + batch = source_sabb.entries[0].batch_no + serial_nos = [entry.serial_no for entry in source_sabb.entries] + + sample_entry = frappe.get_doc( + move_sample_to_retention_warehouse(material_receipt.company, material_receipt.items) + ) + sample_entry.submit() + target_sabb = frappe.get_doc("Serial and Batch Bundle", sample_entry.items[0].serial_and_batch_bundle) + + self.assertEqual(sample_entry.items[0].transfer_qty, 2) + self.assertEqual(target_sabb.entries[0].batch_no, batch) + self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2]) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 0}) +>>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry) def test_raw_material_missing_validation(self): original_value = frappe.db.get_single_value("Manufacturing Settings", "material_consumption") frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0) From 335173c5ba432ee6c269c4a916dc8e9cc0b19a8c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 8 Jun 2026 19:44:48 +0530 Subject: [PATCH 019/132] chore: fix conflicts Reintroduce the _qty_tolerance function to handle float rounding. --- erpnext/stock/doctype/stock_entry/stock_entry.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index cc0813ffd9e..ce2eb0ced74 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -80,17 +80,7 @@ from erpnext.controllers.stock_controller import StockController form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"} -<<<<<<< HEAD class StockEntry(StockController): -======= -def _qty_tolerance(precision: int) -> float: - """One unit at the column's precision -- absorbs float rounding without letting a real - (whole-unit) quantity divergence slip through.""" - return 1.0 / (10**precision) - - -class StockEntry(StockController, SubcontractingInwardController): ->>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry) # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -3491,6 +3481,12 @@ def move_sample_to_retention_warehouse(company, items): return stock_entry.as_dict() +def _qty_tolerance(precision: int) -> float: + """One unit at the column's precision -- absorbs float rounding without letting a real + (whole-unit) quantity divergence slip through.""" + return 1.0 / (10**precision) + + @frappe.whitelist() def make_stock_in_entry(source_name, target_doc=None): def set_missing_values(source, target): From 8bccc25b1ad9d6fb9dd0739bad258f3230b3d233 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 8 Jun 2026 19:45:39 +0530 Subject: [PATCH 020/132] chore: fix conflicts Removed unused test cases related to sample retention stock entry. --- .../doctype/stock_entry/test_stock_entry.py | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 19b435f8481..870091932b6 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2244,8 +2244,6 @@ class TestStockEntry(FrappeTestCase): se.save() se.submit() -<<<<<<< HEAD -======= def test_disassemble_blocks_finished_good_qty_tampering(self): # A disassembly consuming N finished goods must consume exactly N (in stock UOM). # Switching the finished-good row to a larger UOM with a tiny conversion_factor previously @@ -2287,48 +2285,6 @@ class TestStockEntry(FrappeTestCase): self.assertRaises(frappe.ValidationError, se.save) - @ERPNextTestSuite.change_settings( - "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} - ) - def test_sample_retention_stock_entry(self): - from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse - - warehouse = "_Test Warehouse - _TC" - retain_sample_item = make_item( - "Retain Sample Item", - properties={ - "is_stock_item": 1, - "retain_sample": 1, - "sample_quantity": 2, - "has_batch_no": 1, - "has_serial_no": 1, - "create_new_batch": 1, - "batch_number_series": "SAMPLE-RET-.#####", - "serial_no_series": "SAMPLE-RET-SN-.#####", - }, - ) - material_receipt = make_stock_entry( - item_code=retain_sample_item.item_code, target=warehouse, qty=10, purpose="Material Receipt" - ) - - source_sabb = frappe.get_doc( - "Serial and Batch Bundle", material_receipt.items[0].serial_and_batch_bundle - ) - batch = source_sabb.entries[0].batch_no - serial_nos = [entry.serial_no for entry in source_sabb.entries] - - sample_entry = frappe.get_doc( - move_sample_to_retention_warehouse(material_receipt.company, material_receipt.items) - ) - sample_entry.submit() - target_sabb = frappe.get_doc("Serial and Batch Bundle", sample_entry.items[0].serial_and_batch_bundle) - - self.assertEqual(sample_entry.items[0].transfer_qty, 2) - self.assertEqual(target_sabb.entries[0].batch_no, batch) - self.assertEqual([entry.serial_no for entry in target_sabb.entries], serial_nos[:2]) - - @ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 0}) ->>>>>>> 4453c1072a (fix: validate fg and materials qty in the disassemble entry) def test_raw_material_missing_validation(self): original_value = frappe.db.get_single_value("Manufacturing Settings", "material_consumption") frappe.db.set_single_value("Manufacturing Settings", "material_consumption", 0) From cedd0a19035f6e37d17c907478fcb7587cf6f4fb Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:22:54 +0000 Subject: [PATCH 021/132] =?UTF-8?q?fix:=20bypass=20project=20permission=20?= =?UTF-8?q?check=20when=20updating=20consumed=20material=20=E2=80=A6=20(ba?= =?UTF-8?q?ckport=20#55645)=20(#55706)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: bypass project permission check when updating consumed material cost (cherry picked from commit 4b0b7adeee9ce4584bbe2185e1ecba7a6e578c8d) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: pandiyan Co-authored-by: Mihir Kandoi --- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b52945d8928..c3fff06dab2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -680,7 +680,7 @@ class StockEntry(StockController): amount += additional_cost_amt project = frappe.get_doc("Project", self.project) project.total_consumed_material_cost = amount - project.save() + project.save(ignore_permissions=True) def validate_item(self): stock_items = self.get_stock_items() From a94e362b8ce33144b69afb1fcfc94a261374a551 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 8 Jun 2026 23:08:17 +0530 Subject: [PATCH 022/132] fix: sql injection (cherry picked from commit bd0acf4413fa99a3710e997c2c8dcb75e0286c64) # Conflicts: # erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py --- .../inactive_sales_items.py | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py index df3fc48f9e1..f67214dfe35 100644 --- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py +++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder import CustomFunction from frappe.utils import cint @@ -94,19 +95,39 @@ def get_data(filters): def get_sales_details(filters): item_details_map = {} +<<<<<<< HEAD date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date" +======= + if filters["based_on"] not in ("Sales Order", "Sales Invoice"): + frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"])) - sales_data = frappe.db.sql( - """ - select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date, - DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order - from `tab{doctype}` s, `tab{doctype} Item` si - where s.name = si.parent and s.docstatus = 1 - order by days_since_last_order """.format( # nosec - date_field=date_field, doctype=filters["based_on"] - ), - as_dict=1, - ) + parent = frappe.qb.DocType(filters["based_on"]) + child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item" + child = frappe.qb.DocType(child_doctype) +>>>>>>> bd0acf4413 (fix: sql injection) + + date_diff = CustomFunction("DATEDIFF", ["d1", "d2"]) + current_date = CustomFunction("CURRENT_DATE", []) + + date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date + days_since_last_order = date_diff(current_date(), date_col) + + sales_data = ( + frappe.qb.from_(parent) + .inner_join(child) + .on(parent.name == child.parent) + .select( + parent.territory, + parent.customer, + child.item_group, + child.item_code, + child.qty, + date_col.as_("last_order_date"), + days_since_last_order.as_("days_since_last_order"), + ) + .where(parent.docstatus == 1) + .orderby(days_since_last_order) + ).run(as_dict=True) for d in sales_data: item_details_map.setdefault((d.territory, d.item_code), d) From c0f56cd284a1197f66d5ac600c886d45d704e8ce Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 9 Jun 2026 09:31:58 +0530 Subject: [PATCH 023/132] chore: fix conflicts Removed conflicting code related to date_field assignment based on filters. --- .../report/inactive_sales_items/inactive_sales_items.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py index f67214dfe35..6f90cb13398 100644 --- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py +++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py @@ -95,16 +95,12 @@ def get_data(filters): def get_sales_details(filters): item_details_map = {} -<<<<<<< HEAD - date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date" -======= if filters["based_on"] not in ("Sales Order", "Sales Invoice"): frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"])) parent = frappe.qb.DocType(filters["based_on"]) child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item" child = frappe.qb.DocType(child_doctype) ->>>>>>> bd0acf4413 (fix: sql injection) date_diff = CustomFunction("DATEDIFF", ["d1", "d2"]) current_date = CustomFunction("CURRENT_DATE", []) From 2f5b93e308446f0842c413cf9e2c2eb1d9de0d42 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:01:40 +0530 Subject: [PATCH 024/132] fix(cheque_print_template): print format creation from cheque print template requires system manager (backport #55708) (#55711) Co-authored-by: Diptanil Saha fix(cheque_print_template): print format creation from cheque print template requires system manager (#55708) --- .../cheque_print_template.js | 14 +- .../cheque_print_template.json | 1346 ++++------------- .../cheque_print_template.py | 2 + .../address_and_contacts.py | 1 - erpnext/templates/pages/projects.py | 1 + 5 files changed, 304 insertions(+), 1060 deletions(-) diff --git a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.js b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.js index 17cb8d00e48..7ce14b6c235 100644 --- a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.js +++ b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.js @@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print"); frappe.ui.form.on("Cheque Print Template", { refresh: function (frm) { if (!frm.doc.__islocal) { - frm.add_custom_button( - frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"), - function () { - erpnext.cheque_print.view_cheque_print(frm); - } - ).addClass("btn-primary"); + if (frappe.user.has_role("System Manager")) { + frm.add_custom_button( + frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"), + function () { + erpnext.cheque_print.view_cheque_print(frm); + } + ).addClass("btn-primary"); + } $(frm.fields_dict.cheque_print_preview.wrapper).empty(); diff --git a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.json b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.json index e9993c34268..155fc43c5f7 100644 --- a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.json +++ b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.json @@ -1,1102 +1,342 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "field:bank_name", - "beta": 0, - "creation": "2016-05-04 14:35:00.402544", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", + "actions": [], + "allow_bulk_edit": 1, + "autoname": "field:bank_name", + "creation": "2016-05-04 14:35:00.402544", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "settings", + "has_print_format", + "primary_settings", + "bank_name", + "cheque_size", + "starting_position_from_top_edge", + "cheque_width", + "cheque_height", + "scanned_cheque", + "column_break_5", + "is_account_payable", + "acc_pay_dist_from_top_edge", + "acc_pay_dist_from_left_edge", + "message_to_show", + "date_and_payer_settings", + "date_settings", + "date_dist_from_top_edge", + "date_dist_from_left_edge", + "payer_settings", + "payer_name_from_top_edge", + "payer_name_from_left_edge", + "amount_in_words_and_figure_settings", + "html_19", + "amt_in_words_from_top_edge", + "amt_in_words_from_left_edge", + "amt_in_word_width", + "amt_in_words_line_spacing", + "amount_in_figure", + "amt_in_figures_from_top_edge", + "amt_in_figures_from_left_edge", + "account_number_and_signatory_settings", + "account_no_settings", + "acc_no_dist_from_top_edge", + "acc_no_dist_from_left_edge", + "signatory_position", + "signatory_from_top_edge", + "signatory_from_left_edge", + "preview", + "cheque_print_preview" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "description": "", - "fieldname": "settings", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "", - "length": 0, - "no_copy": 0, - "options": "
\n

All dimensions in centimeter only

\n
", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "settings", + "fieldtype": "HTML", + "options": "
\n

All dimensions in centimeter only

\n
" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "has_print_format", - "fieldtype": "Check", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Has Print Format", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "has_print_format", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Print Format", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "primary_settings", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Primary Settings", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "primary_settings", + "fieldtype": "Section Break", + "label": "Primary Settings" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "bank_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Bank Name", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "bank_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Bank Name", + "no_copy": 1, + "reqd": 1, + "unique": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "Regular", - "fieldname": "cheque_size", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Cheque Size", - "length": 0, - "no_copy": 0, - "options": "\nRegular\nA4", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "Regular", + "fieldname": "cheque_size", + "fieldtype": "Select", + "label": "Cheque Size", + "options": "\nRegular\nA4" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "depends_on": "eval:doc.cheque_size==\"A4\"", - "fieldname": "starting_position_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting position from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "depends_on": "eval:doc.cheque_size==\"A4\"", + "fieldname": "starting_position_from_top_edge", + "fieldtype": "Float", + "label": "Starting position from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "20.00", - "fieldname": "cheque_width", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Cheque Width", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "20.00", + "fieldname": "cheque_width", + "fieldtype": "Float", + "label": "Cheque Width", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "9.00", - "fieldname": "cheque_height", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Cheque Height", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "9.00", + "fieldname": "cheque_height", + "fieldtype": "Float", + "label": "Cheque Height", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "", - "fieldname": "scanned_cheque", - "fieldtype": "Attach", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Scanned Cheque", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "scanned_cheque", + "fieldtype": "Attach", + "label": "Scanned Cheque" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "column_break_5", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "1", - "fieldname": "is_account_payable", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Is Account Payable", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "is_account_payable", + "fieldtype": "Check", + "label": "Is Account Payable" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "1.00", - "depends_on": "eval:doc.is_account_payable", - "fieldname": "acc_pay_dist_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "1.00", + "depends_on": "eval:doc.is_account_payable", + "fieldname": "acc_pay_dist_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "9.00", - "depends_on": "eval:doc.is_account_payable", - "fieldname": "acc_pay_dist_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "9.00", + "depends_on": "eval:doc.is_account_payable", + "fieldname": "acc_pay_dist_from_left_edge", + "fieldtype": "Float", + "label": "Distance from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "Acc. Payee", - "depends_on": "eval:doc.is_account_payable", - "fieldname": "message_to_show", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Message to show", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "Acc. Payee", + "depends_on": "eval:doc.is_account_payable", + "fieldname": "message_to_show", + "fieldtype": "Data", + "label": "Message to show" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "date_and_payer_settings", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "date_and_payer_settings", + "fieldtype": "Section Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "date_settings", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Date Settings", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "date_settings", + "fieldtype": "HTML", + "label": "Date Settings", + "options": "" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "1.00", - "fieldname": "date_dist_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "1.00", + "fieldname": "date_dist_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "15.00", - "depends_on": "", - "fieldname": "date_dist_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "15.00", + "fieldname": "date_dist_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "payer_settings", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Payer Settings", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "payer_settings", + "fieldtype": "Column Break", + "label": "Payer Settings" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "2.00", - "fieldname": "payer_name_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "2.00", + "fieldname": "payer_name_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "3.00", - "fieldname": "payer_name_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "3.00", + "fieldname": "payer_name_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "amount_in_words_and_figure_settings", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "amount_in_words_and_figure_settings", + "fieldtype": "Section Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "html_19", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "html_19", + "fieldtype": "HTML", + "options": "" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "3.00", - "fieldname": "amt_in_words_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "3.00", + "fieldname": "amt_in_words_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "4.00", - "fieldname": "amt_in_words_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "4.00", + "fieldname": "amt_in_words_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "15.00", - "fieldname": "amt_in_word_width", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Width of amount in word", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "15.00", + "fieldname": "amt_in_word_width", + "fieldtype": "Float", + "label": "Width of amount in word", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "0.50", - "fieldname": "amt_in_words_line_spacing", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Line spacing for amount in words", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "0.50", + "fieldname": "amt_in_words_line_spacing", + "fieldtype": "Float", + "label": "Line spacing for amount in words", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "amount_in_figure", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Amount In Figure", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "amount_in_figure", + "fieldtype": "Column Break", + "label": "Amount In Figure" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "3.50", - "fieldname": "amt_in_figures_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "3.50", + "fieldname": "amt_in_figures_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "16.00", - "fieldname": "amt_in_figures_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "16.00", + "fieldname": "amt_in_figures_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "account_number_and_signatory_settings", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "account_number_and_signatory_settings", + "fieldtype": "Section Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "account_no_settings", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "account_no_settings", + "fieldtype": "HTML", + "options": "" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "5.00", - "fieldname": "acc_no_dist_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "5.00", + "fieldname": "acc_no_dist_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "4.00", - "fieldname": "acc_no_dist_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "4.00", + "fieldname": "acc_no_dist_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "signatory_position", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Signatory Position", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "signatory_position", + "fieldtype": "Column Break", + "label": "Signatory Position" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "6.00", - "fieldname": "signatory_from_top_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Distance from top edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "6.00", + "fieldname": "signatory_from_top_edge", + "fieldtype": "Float", + "label": "Distance from top edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "default": "15.00", - "fieldname": "signatory_from_left_edge", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Starting location from left edge", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "2", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "15.00", + "fieldname": "signatory_from_left_edge", + "fieldtype": "Float", + "label": "Starting location from left edge", + "precision": "2" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "preview", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "label": "Preview", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "preview", + "fieldtype": "Section Break", + "label": "Preview" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "cheque_print_preview", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "cheque_print_preview", + "fieldtype": "HTML" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 1, - "modified": "2016-06-23 20:19:11.694932", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Cheque Print Template", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "max_attachments": 1, + "modified": "2026-06-08 12:10:35.829531", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Cheque Print Template", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_seen": 0 -} \ No newline at end of file + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.py b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.py index 4b1394ede17..258ea7bb8d7 100644 --- a/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.py +++ b/erpnext/accounts/doctype/cheque_print_template/cheque_print_template.py @@ -48,6 +48,8 @@ class ChequePrintTemplate(Document): @frappe.whitelist() def create_or_update_cheque_print_format(template_name): + frappe.only_for("System Manager") + if not frappe.db.exists("Print Format", template_name): cheque_print = frappe.new_doc("Print Format") cheque_print.update( diff --git a/erpnext/selling/report/address_and_contacts/address_and_contacts.py b/erpnext/selling/report/address_and_contacts/address_and_contacts.py index 0b08a24484f..ed25dd5f153 100644 --- a/erpnext/selling/report/address_and_contacts/address_and_contacts.py +++ b/erpnext/selling/report/address_and_contacts/address_and_contacts.py @@ -20,7 +20,6 @@ field_map = { } - def execute(filters=None): columns, data = get_columns(filters), get_data(filters) return columns, data diff --git a/erpnext/templates/pages/projects.py b/erpnext/templates/pages/projects.py index 22750d39aea..8f153105e3e 100644 --- a/erpnext/templates/pages/projects.py +++ b/erpnext/templates/pages/projects.py @@ -88,6 +88,7 @@ def get_timesheets(project, start=0, search=None): timesheet.update(info[0]) return timesheets + def get_attachments(project): return frappe.get_all( "File", From a5c23a3d16dedcb05bf05e924b8630e1dec44e41 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Tue, 9 Jun 2026 13:13:23 +0530 Subject: [PATCH 025/132] fix: restrict already invoiced qty in intercompany purchase invoice (#55754) --- .../doctype/sales_invoice/sales_invoice.js | 21 ++++++- .../doctype/sales_invoice/sales_invoice.py | 28 +++++---- .../sales_invoice/test_sales_invoice.py | 61 +++++++++++++++++++ 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 61b9cf1fc06..259cd970941 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -180,12 +180,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( : "Inter Company Purchase Invoice"; me.frm.add_custom_button( - button_label, + __(button_label), function () { me.make_inter_company_invoice(); }, __("Create") ); + + frappe.call({ + method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.get_received_items", + args: { + reference_name: me.frm.doc.name, + doctype: "Purchase Invoice", + reference_fieldname: "sales_invoice_item", + }, + callback: function (r) { + if (r.exc) return; + const received_items = r.message || {}; + const has_pending_qty = me.frm.doc.items.some( + (item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0 + ); + if (!has_pending_qty) { + me.frm.remove_custom_button(__(button_label), __("Create")); + } + }, + }); } } diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 3e16c503e69..df76ef6831e 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -2526,7 +2526,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "rate": "rate", }, "postprocess": update_item, - "condition": lambda doc: doc.qty > 0, + "condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0, } if doctype in ["Sales Invoice", "Sales Order"]: @@ -2566,11 +2566,18 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): target_doc, set_missing_values, ) - + if not doclist.get("items"): + frappe.throw( + _( + "Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. " + "Please check the existing linked {2}s." + ).format(target_doctype, doctype, target_doctype) + ) return doclist -def get_received_items(reference_name, doctype, reference_fieldname): +@frappe.whitelist() +def get_received_items(reference_name: str, doctype: str, reference_fieldname: str): reference_field = "inter_company_invoice_reference" if doctype == "Purchase Order": reference_field = "inter_company_order_reference" @@ -2583,20 +2590,19 @@ def get_received_items(reference_name, doctype, reference_fieldname): target_doctypes = frappe.get_all( doctype, filters=filters, - as_list=True, + pluck="name", ) - + received_items_map = {} if target_doctypes: - target_doctypes = list(target_doctypes[0]) - - received_items_map = frappe._dict( - frappe.get_all( + received_items_data = frappe.get_all( doctype + " Item", filters={"parent": ("in", target_doctypes)}, fields=[reference_fieldname, "qty"], - as_list=1, ) - ) + for item in received_items_data: + key = item.get(reference_fieldname) + if key: + received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty) return received_items_map diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 147bb76064d..ef7c25c86a9 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2690,6 +2690,67 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(target_doc.company, "_Test Company 1") self.assertEqual(target_doc.supplier, "_Test Internal Supplier") + def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self): + item_code_1 = "_Test IC Item 1" + item_code_2 = "_Test IC Item 2" + + create_item(item_code_1, is_stock_item=1) + create_item(item_code_2, is_stock_item=1) + + si = create_sales_invoice( + company="Wind Power LLC", + customer="_Test Internal Customer", + item_code=item_code_1, + debit_to="Debtors - WP", + warehouse="Stores - WP", + income_account="Sales - WP", + expense_account="Cost of Goods Sold - WP", + cost_center="Main - WP", + currency="USD", + qty=3, + do_not_save=1, + ) + si.selling_price_list = "_Test Price List Rest of the World" + si.append( + "items", + { + "item_code": item_code_2, + "item_name": item_code_2, + "description": item_code_2, + "warehouse": "Stores - WP", + "qty": 2, + "uom": "Nos", + "stock_uom": "Nos", + "rate": 100, + "price_list_rate": 100, + "income_account": "Sales - WP", + "expense_account": "Cost of Goods Sold - WP", + "cost_center": "Main - WP", + "conversion_factor": 1, + }, + ) + + si.submit() + + target_doc = make_inter_company_transaction("Sales Invoice", si.name) + + for item in target_doc.items: + item.update( + { + "expense_account": "Cost of Goods Sold - _TC1", + "cost_center": "Main - _TC1", + } + ) + + target_doc.submit() + self.assertEqual(len(target_doc.items), 2) + self.assertEqual([item.qty for item in target_doc.items], [3, 2]) + with self.assertRaisesRegex( + frappe.ValidationError, + "already been fully invoiced", + ): + make_inter_company_transaction("Sales Invoice", si.name) + def test_inter_company_transaction_without_default_warehouse(self): "Check mapping (expense account) of inter company SI to PI in absence of default warehouse." # setup From eef075a2bac9b4b37ba73d8b58da00a6fed6c65d Mon Sep 17 00:00:00 2001 From: Lakshit Jain Date: Tue, 9 Jun 2026 17:52:19 +0530 Subject: [PATCH 026/132] fix: update round off account functions to accept document context for regional overrides (#55758) (cherry picked from commit 08129ff71c2279e803ec95564b9f4837a599c42b) # Conflicts: # erpnext/controllers/taxes_and_totals.py # erpnext/controllers/tests/test_taxes_and_totals.py # erpnext/public/js/controllers/taxes_and_totals.js --- erpnext/controllers/taxes_and_totals.py | 15 +++++++++-- .../tests/test_taxes_and_totals.py | 26 +++++++++++++++++++ .../public/js/controllers/taxes_and_totals.js | 9 +++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 54aee03e083..8b2a02ce290 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -26,9 +26,14 @@ from erpnext.utilities.regional import temporary_flag class calculate_taxes_and_totals: def __init__(self, doc: Document): self.doc = doc +<<<<<<< HEAD frappe.flags.round_off_applicable_accounts = [] frappe.flags.round_row_wise_tax = frappe.db.get_single_value( "Accounts Settings", "round_row_wise_tax" +======= + frappe.flags.round_off_applicable_accounts = ( + get_round_off_applicable_accounts(self.doc.company, [], self.doc) or [] +>>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) ) if doc.get("round_off_applicable_accounts_for_tax_withholding"): @@ -1128,14 +1133,20 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() +<<<<<<< HEAD def get_round_off_applicable_accounts(company, account_list): +======= +def get_round_off_applicable_accounts( + company: str, account_list: list | str, doc: str | dict | Document | None = None +): +>>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) # required to set correct region with temporary_flag("company", company): - return get_regional_round_off_accounts(company, account_list) + return get_regional_round_off_accounts(company, account_list, doc) @erpnext.allow_regional -def get_regional_round_off_accounts(company, account_list): +def get_regional_round_off_accounts(company, account_list, doc=None): pass diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index 504716f4684..b86e60a027b 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -5,7 +5,33 @@ from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +<<<<<<< HEAD class TestTaxesAndTotals(FrappeTestCase): +======= +class TestTaxesAndTotals(ERPNextTestSuite): + def test_regional_round_off_accounts(self): + """ + Regional overrides cannot extend the list in-place — the return + value must be assigned back to frappe.flags.round_off_applicable_accounts. + """ + test_account = "_Test Round Off Account" + + def mock_regional(company, account_list: list, doc=None) -> list: + # Simulates a regional override + account_list.extend([test_account]) + return account_list + + so = make_sales_order(do_not_save=True) + + with patch( + "erpnext.controllers.taxes_and_totals.get_regional_round_off_accounts", + mock_regional, + ): + calculate_taxes_and_totals(so) + + self.assertIn(test_account, frappe.flags.round_off_applicable_accounts) + +>>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) def test_disabling_rounded_total_resets_base_fields(self): """Disabling rounded total should also clear base rounded values.""" so = make_sales_order(do_not_save=True) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 7b07c13bf11..924bf4534ac 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -204,10 +204,19 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (me.frm.doc.company) { frappe.call({ +<<<<<<< HEAD "method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts", "args": { "company": me.frm.doc.company, "account_list": frappe.flags.round_off_applicable_accounts +======= + method: "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts", + args: { + company: me.frm.doc.company, + account_list: frappe.flags.round_off_applicable_accounts, + // pass the doc so regional overrides can inspect it + doc: me.frm.doc, +>>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) }, callback(r) { if (r.message) { From 5629056ec267e9bb72b316f5f661f7a99e534e7d Mon Sep 17 00:00:00 2001 From: ljain112 Date: Tue, 9 Jun 2026 18:12:53 +0530 Subject: [PATCH 027/132] chore: resolve conflicts --- erpnext/controllers/taxes_and_totals.py | 13 +++---------- erpnext/controllers/tests/test_taxes_and_totals.py | 6 ++---- erpnext/public/js/controllers/taxes_and_totals.js | 12 ++---------- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 8b2a02ce290..09318b6725f 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -26,14 +26,9 @@ from erpnext.utilities.regional import temporary_flag class calculate_taxes_and_totals: def __init__(self, doc: Document): self.doc = doc -<<<<<<< HEAD frappe.flags.round_off_applicable_accounts = [] frappe.flags.round_row_wise_tax = frappe.db.get_single_value( "Accounts Settings", "round_row_wise_tax" -======= - frappe.flags.round_off_applicable_accounts = ( - get_round_off_applicable_accounts(self.doc.company, [], self.doc) or [] ->>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) ) if doc.get("round_off_applicable_accounts_for_tax_withholding"): @@ -43,7 +38,9 @@ class calculate_taxes_and_totals: self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items") - get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts) + get_round_off_applicable_accounts( + self.doc.company, frappe.flags.round_off_applicable_accounts, self.doc + ) self.calculate() def filter_rows(self): @@ -1133,13 +1130,9 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() -<<<<<<< HEAD -def get_round_off_applicable_accounts(company, account_list): -======= def get_round_off_applicable_accounts( company: str, account_list: list | str, doc: str | dict | Document | None = None ): ->>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) # required to set correct region with temporary_flag("company", company): return get_regional_round_off_accounts(company, account_list, doc) diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py index b86e60a027b..715acf8782f 100644 --- a/erpnext/controllers/tests/test_taxes_and_totals.py +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import frappe from frappe.tests.utils import FrappeTestCase @@ -5,10 +7,7 @@ from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order -<<<<<<< HEAD class TestTaxesAndTotals(FrappeTestCase): -======= -class TestTaxesAndTotals(ERPNextTestSuite): def test_regional_round_off_accounts(self): """ Regional overrides cannot extend the list in-place — the return @@ -31,7 +30,6 @@ class TestTaxesAndTotals(ERPNextTestSuite): self.assertIn(test_account, frappe.flags.round_off_applicable_accounts) ->>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) def test_disabling_rounded_total_resets_base_fields(self): """Disabling rounded total should also clear base rounded values.""" so = make_sales_order(do_not_save=True) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 924bf4534ac..0df4cabfb4f 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -204,19 +204,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (me.frm.doc.company) { frappe.call({ -<<<<<<< HEAD "method": "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts", "args": { "company": me.frm.doc.company, - "account_list": frappe.flags.round_off_applicable_accounts -======= - method: "erpnext.controllers.taxes_and_totals.get_round_off_applicable_accounts", - args: { - company: me.frm.doc.company, - account_list: frappe.flags.round_off_applicable_accounts, - // pass the doc so regional overrides can inspect it - doc: me.frm.doc, ->>>>>>> 08129ff71c (fix: update round off account functions to accept document context for regional overrides (#55758)) + "account_list": frappe.flags.round_off_applicable_accounts, + "doc": me.frm.doc, }, callback(r) { if (r.message) { From 0e64acb0fa04042268805e11fa4f7b4a082708aa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:23:49 +0000 Subject: [PATCH 028/132] fix: prevent leakage of party-derived fields in cross doctype transactions (backport #55336) (#55578) Co-authored-by: Nabin Hait Co-authored-by: Lakshit Jain fix: prevent leakage of party-derived fields in cross doctype transactions (#55336) --- .../doctype/sales_invoice/sales_invoice.py | 9 ++++- .../sales_invoice/test_sales_invoice.py | 28 ++++++++++++++ erpnext/accounts/party.py | 19 ++++++++++ .../doctype/sales_order/sales_order.py | 35 +++++------------ .../doctype/sales_order/test_sales_order.py | 35 ++++++++++++++--- .../doctype/delivery_note/delivery_note.py | 5 +-- .../purchase_receipt/test_purchase_receipt.py | 38 +++++++++++++++++++ 7 files changed, 133 insertions(+), 36 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index df76ef6831e..5d6676aa537 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -23,7 +23,12 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category get_party_tax_withholding_details, ) from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center -from erpnext.accounts.party import get_due_date, get_party_account, get_party_details +from erpnext.accounts.party import ( + CROSS_PARTY_FIELD_NO_MAP, + get_due_date, + get_party_account, + get_party_details, +) from erpnext.accounts.utils import ( cancel_exchange_gain_loss_journal, get_account_currency, @@ -2559,7 +2564,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): "doctype": target_doctype, "postprocess": update_details, "set_target_warehouse": "set_from_warehouse", - "field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"], }, doctype + " Item": item_field_map, }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index ef7c25c86a9..e210903c450 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2751,6 +2751,34 @@ class TestSalesInvoice(FrappeTestCase): ): make_inter_company_transaction("Sales Invoice", si.name) + def test_inter_company_transaction_does_not_inherit_party_fields(self): + """ + Party-derived fields on SI (from Customer) must not leak into the mapped PI. + """ + si = create_sales_invoice( + company="Wind Power LLC", + customer="_Test Internal Customer", + debit_to="Debtors - WP", + warehouse="Stores - WP", + income_account="Sales - WP", + expense_account="Cost of Goods Sold - WP", + cost_center="Main - WP", + currency="USD", + do_not_save=1, + ) + si.selling_price_list = "_Test Price List Rest of the World" + si.tax_category = "_Test Tax Category 1" + si.language = "ar" + si.payment_terms_template = "_Test Payment Term Template" + si.submit() + + pi = make_inter_company_transaction("Sales Invoice", si.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier") + self.assertEqual(pi.tax_category or None, supplier.tax_category or None) + self.assertEqual(pi.language or None, supplier.language or None) + self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None) + def test_inter_company_transaction_without_default_warehouse(self): "Check mapping (expense account) of inter company SI to PI in absence of default warehouse." # setup diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index a5d940d7b7f..5c0860b4fd3 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -48,6 +48,25 @@ SALES_TRANSACTION_TYPES = { } TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES +# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the +# source and target documents belong to different parties (e.g. Sales Order → +# Purchase Order or inter-company Sales Invoice → Purchase Invoice). +CROSS_PARTY_FIELD_NO_MAP = [ + "tax_category", + "tax_id", + "tax_withholding_category", + "taxes_and_charges", + "address_display", + "contact_display", + "contact_mobile", + "contact_email", + "contact_person", + "shipping_address", + "dispatch_address", + "payment_terms_template", + "language", +] + class DuplicatePartyAccountError(frappe.ValidationError): pass diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 856a75606bc..1dd62a65239 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -10,6 +10,7 @@ import frappe.utils from frappe import _, qb from frappe.contacts.doctype.address.address import get_company_address from frappe.desk.notifications import clear_doctype_notifications +from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder.functions import Sum @@ -20,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.party import get_party_account +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, @@ -1332,7 +1333,9 @@ def get_events(start, end, filters=None): @frappe.whitelist() -def make_purchase_order_for_default_supplier(source_name, selected_items=None, target_doc=None): +def make_purchase_order_for_default_supplier( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): """Creates Purchase Order for each Supplier. Returns a list of doc objects.""" from erpnext.setup.utils import get_exchange_rate @@ -1361,7 +1364,6 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") @@ -1418,16 +1420,7 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { @@ -1492,7 +1485,9 @@ def make_purchase_order_for_default_supplier(source_name, selected_items=None, t @frappe.whitelist() -def make_purchase_order(source_name, selected_items=None, target_doc=None): +def make_purchase_order( + source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None +): if not selected_items: return @@ -1520,7 +1515,6 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] if is_drop_ship_order(target): @@ -1559,16 +1553,7 @@ def make_purchase_order(source_name, selected_items=None, target_doc=None): { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 18d416ad7df..6f8befeb876 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -24,6 +24,8 @@ from erpnext.selling.doctype.sales_order.sales_order import ( create_pick_list, make_delivery_note, make_material_request, + make_purchase_order, + make_purchase_order_for_default_supplier, make_raw_material_request, make_sales_invoice, make_work_orders, @@ -1364,8 +1366,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): Tests if the the Product Bundles in the Items table of Sales Orders are replaced with their child items(from the Packed Items table) on creating a Purchase Order from it. """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -1394,8 +1394,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -2419,8 +2417,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertRaises(frappe.ValidationError, so1.update_status, "Draft") def test_item_tax_transfer_from_sales_to_purchase(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - item_tax = frappe.new_doc("Item Tax Template") item_tax.title = "Test Item Tax Template" item_tax.company = "_Test Company" @@ -2521,6 +2517,33 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertFalse(so.per_billed) self.assertEqual(so.status, "To Deliver and Bill") + def test_make_purchase_order_does_not_inherit_party_fields(self): + """ + Customer-derived fields must not leak from a drop-ship SO into the PO. + """ + so_items = [ + { + "item_code": "_Test Item", + "warehouse": "", + "qty": 1, + "rate": 100, + "delivered_by_supplier": 1, + "supplier": "_Test Supplier", + } + ] + so = make_sales_order(item_list=so_items, do_not_submit=True) + so.tax_category = "_Test Tax Category 1" + so.language = "ar" + so.payment_terms_template = "_Test Payment Term Template" + so.submit() + + po = make_purchase_order_for_default_supplier(so.name, selected_items=so_items)[0] + + supplier = frappe.get_doc("Supplier", "_Test Supplier") + self.assertEqual(po.tax_category or None, supplier.tax_category or None) + self.assertEqual(po.language or None, supplier.language or None) + self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None) + def test_pending_quantity_after_update_item_during_invoice_creation(self): so = make_sales_order(qty=30, rate=100) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index f1fc54c751d..0ad8bc781a5 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -15,7 +15,7 @@ from frappe.query_builder import DocType from frappe.query_builder.functions import Abs, Sum from frappe.utils import cint, flt -from erpnext.accounts.party import get_due_date +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController from erpnext.stock.stock_ledger import validate_reserved_stock @@ -1405,8 +1405,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): doctype: { "doctype": target_doctype, "postprocess": update_details, - "field_no_map": ["taxes_and_charges", "set_warehouse"], - "field_map": {"shipping_address_name": "shipping_address"}, + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"], }, doctype + " Item": { "doctype": target_doctype + " Item", diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index ce23acf85e2..3799e773a7d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -1050,6 +1050,44 @@ class TestPurchaseReceipt(FrappeTestCase): pr.cancel() + def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self): + """ + Party-derived fields on DN (from Customer) must not leak into the mapped PR. + """ + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + stock = make_purchase_receipt(warehouse="Stores - TCP1", company=company) + + dn = create_delivery_note( + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + do_not_submit=True, + ) + # Stamp customer-side party fields onto the DN + dn.tax_category = "_Test Tax Category 2" + dn.language = "ar" + dn.submit() + + pr = make_inter_company_purchase_receipt(dn.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2") + self.assertEqual(pr.tax_category or None, supplier.tax_category or None) + self.assertEqual(pr.language or None, supplier.language or None) + dn.cancel() + stock.cancel() + def test_lcv_for_internal_transfer(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note From 95f46dfc015517ca390b29ad27c46fa4575f7fb2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 9 Jun 2026 19:15:52 +0530 Subject: [PATCH 029/132] fix: handle multi-select stock ageing filters (#55776) --- .../stock/report/stock_ageing/stock_ageing.py | 35 ++++++++----------- .../report/stock_ageing/test_stock_ageing.py | 12 +++++++ 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 93fac84d60a..238b72fa957 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -11,6 +11,7 @@ from frappe.query_builder.functions import Abs, Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter from erpnext.stock.valuation import round_off_if_near_zero Filters = frappe._dict @@ -936,9 +937,7 @@ class FIFOSlots: ) ) - for field in ["item_code"]: - if self.filters.get(field): - query = query.where(sle[field] == self.filters.get(field)) + query = self._apply_filter(query, sle, "item_code") if self.filters.get("warehouse"): query = self._get_warehouse_conditions(sle, query) @@ -985,9 +984,7 @@ class FIFOSlots: ) ) - for field in ["item_code"]: - if self.filters.get(field): - query = query.where(sle[field] == self.filters.get(field)) + query = self._apply_filter(query, sle, "item_code") if self.filters.get("warehouse"): query = self._get_warehouse_conditions(sle, query) @@ -1020,27 +1017,25 @@ class FIFOSlots: "has_batch_no", ) - if self.filters.get("item_code"): - item = item.where(item_table.item_code == self.filters.get("item_code")) + item = self._apply_filter(item, item_table, "item_code") if self.filters.get("brand"): item = item.where(item_table.brand == self.filters.get("brand")) return item + def _apply_filter(self, query, table, fieldname: str): + filter_value = self.filters.get(fieldname) + if not filter_value: + return query + + if isinstance(filter_value, list | tuple | set): + return query.where(table[fieldname].isin(filter_value)) + + return query.where(table[fieldname] == filter_value) + def _get_warehouse_conditions(self, sle, sle_query) -> str: - warehouse = frappe.qb.DocType("Warehouse") - lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"]) - - warehouse_results = ( - frappe.qb.from_(warehouse) - .select("name") - .where((warehouse.lft >= lft) & (warehouse.rgt <= rgt)) - .run() - ) - warehouse_results = [x[0] for x in warehouse_results] - - return sle_query.where(sle.warehouse.isin(warehouse_results)) + return apply_warehouse_filter(sle_query, sle, self.filters) def prepare_stock_reco_voucher_wise_count(self): self.stock_reco_voucher_wise_count = frappe._dict() diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 601524c74e9..1c61306125e 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -129,6 +129,18 @@ class TestStockAgeing(FrappeTestCase): self.assertEqual(queue[0][0], 10.0) self.assertEqual(queue[1][0], 10.0) + def test_item_filter_supports_multi_select_values(self): + bundle = frappe.qb.DocType("Serial and Batch Bundle") + query = frappe.qb.from_(bundle).select(bundle.name) + + filtered_query = FIFOSlots(frappe._dict(item_code=["Item A"]), [])._apply_filter( + query, bundle, "item_code" + ) + + sql = filtered_query.get_sql() + self.assertIn(" IN ", sql) + self.assertNotIn("=[", sql) + def test_basic_stock_reconciliation(self): """ Ledger (same wh): [+30, reco reset >> 50, -10] From 18ca96c36ba65362bf1c25abb8eab32c64c6c7dd Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:10:24 +0000 Subject: [PATCH 030/132] fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (backport #55781) (#55782) Co-authored-by: Diptanil Saha fix(process statement of accounts): validate pdf_name and validate permission before triggering send_auto_email (#55781) --- .../process_statement_of_accounts.py | 2 ++ 1 file changed, 2 insertions(+) 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 273aa6c03de..26a45bbdc55 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 @@ -99,6 +99,7 @@ class ProcessStatementOfAccounts(Document): validate_template(self.subject) validate_template(self.body) + validate_template(self.pdf_name) if not self.customers: frappe.throw(_("Customers not selected.")) @@ -548,6 +549,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None): @frappe.whitelist() def send_auto_email(): + frappe.has_permission("Process Statement Of Accounts", throw=True) selected = frappe.get_list( "Process Statement Of Accounts", filters={"enable_auto_email": 1}, From 2b3e3dfd83558539f79bc6a5ecd5462f6678dd21 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Wed, 10 Jun 2026 00:24:00 +0000 Subject: [PATCH 031/132] chore(release): Bumped to Version 15.111.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # [15.111.0](https://github.com/frappe/erpnext/compare/v15.110.0...v15.111.0) (2026-06-10) ### Bug Fixes * **accounts:** include asset items in purchase receipt validation ([#55150](https://github.com/frappe/erpnext/issues/55150)) ([611849f](https://github.com/frappe/erpnext/commit/611849f9534068d621f35cec155f83d9eebcade4)) * add permission checks in accounts whitelisted methods ([2d1c0dc](https://github.com/frappe/erpnext/commit/2d1c0dcb53e4ff1069ceb39f67063cb954f578dd)) * bypass project permission check when updating consumed material … (backport [#55645](https://github.com/frappe/erpnext/issues/55645)) ([#55706](https://github.com/frappe/erpnext/issues/55706)) ([cedd0a1](https://github.com/frappe/erpnext/commit/cedd0a19035f6e37d17c907478fcb7587cf6f4fb)) * **cheque_print_template:** print format creation from cheque print template requires system manager (backport [#55708](https://github.com/frappe/erpnext/issues/55708)) ([#55711](https://github.com/frappe/erpnext/issues/55711)) ([2f5b93e](https://github.com/frappe/erpnext/commit/2f5b93e308446f0842c413cf9e2c2eb1d9de0d42)) * correct field order in Address and Contacts report ([bde46cf](https://github.com/frappe/erpnext/commit/bde46cffd0e2321860435eceeaa6c59589ef315c)) * correct field order in Address and Contacts report ([08f3cf9](https://github.com/frappe/erpnext/commit/08f3cf98f9e0738a01ca8a5809fdbfb1b7bb6104)) * do not allow to make changes in SABB after submit ([07b6111](https://github.com/frappe/erpnext/commit/07b61113af40c5a247b1cb51c6bdb4afc8e83eeb)) * drop ignore_permissions handling from add_ac ([2d0e3fd](https://github.com/frappe/erpnext/commit/2d0e3fd9af521e407be35fd09e9427176609513f)) * duplicating a Customer/Supplier shouldn't inherit the source's primary contact and address (backport [#55421](https://github.com/frappe/erpnext/issues/55421)) ([#55608](https://github.com/frappe/erpnext/issues/55608)) ([013bd1a](https://github.com/frappe/erpnext/commit/013bd1a5664a7f3a5b65f26b77a63644e06f68eb)) * handle multi-select stock ageing filters ([#55776](https://github.com/frappe/erpnext/issues/55776)) ([95f46df](https://github.com/frappe/erpnext/commit/95f46dfc015517ca390b29ad27c46fa4575f7fb2)) * **item:** format integer numeric variant attributes without decimals (backport [#55561](https://github.com/frappe/erpnext/issues/55561)) ([#55563](https://github.com/frappe/erpnext/issues/55563)) ([4772799](https://github.com/frappe/erpnext/commit/4772799db22292cf236a4a8759938f726a691b59)) * naming series issue ([e7eaa87](https://github.com/frappe/erpnext/commit/e7eaa87a77db77b0d421b1aa3c70a5024c4e378f)) * persist main item code for MR plan items ([#55623](https://github.com/frappe/erpnext/issues/55623)) ([e8e0514](https://github.com/frappe/erpnext/commit/e8e0514a30e3c8514c25c85e74e7d36ad612dc67)) * prevent leakage of party-derived fields in cross doctype transactions (backport [#55336](https://github.com/frappe/erpnext/issues/55336)) ([#55578](https://github.com/frappe/erpnext/issues/55578)) ([0e64acb](https://github.com/frappe/erpnext/commit/0e64acb0fa04042268805e11fa4f7b4a082708aa)) * prevent selling items from sample retention warehouse (backport [#55613](https://github.com/frappe/erpnext/issues/55613)) ([#55633](https://github.com/frappe/erpnext/issues/55633)) ([c15012c](https://github.com/frappe/erpnext/commit/c15012cd51c80baff894081aa25472a6c6d53959)) * **process statement of accounts:** validate pdf_name and validate permission before triggering send_auto_email (backport [#55781](https://github.com/frappe/erpnext/issues/55781)) ([#55782](https://github.com/frappe/erpnext/issues/55782)) ([18ca96c](https://github.com/frappe/erpnext/commit/18ca96c36ba65362bf1c25abb8eab32c64c6c7dd)) * remove item name from update items dialog item code column (backport [#55718](https://github.com/frappe/erpnext/issues/55718)) ([#55722](https://github.com/frappe/erpnext/issues/55722)) ([09453f8](https://github.com/frappe/erpnext/commit/09453f883ba8ea1a355d044d3f416f8925d663df)) * resolve conflict ([271ddb6](https://github.com/frappe/erpnext/commit/271ddb6add5f062fdc33750c21e124ae275d7bc3)) * restrict already invoiced qty in intercompany purchase invoice ([#55754](https://github.com/frappe/erpnext/issues/55754)) ([a5c23a3](https://github.com/frappe/erpnext/commit/a5c23a3d16dedcb05bf05e924b8630e1dec44e41)) * **selling:** consider delivered qty (backport [#55597](https://github.com/frappe/erpnext/issues/55597)) ([#55606](https://github.com/frappe/erpnext/issues/55606)) ([e8267e3](https://github.com/frappe/erpnext/commit/e8267e32377e264e44e829fdf2e27b610f5fe28b)) * simplify New Zealand sales accounts ([eebb37f](https://github.com/frappe/erpnext/commit/eebb37f9fddf00b559f33de8d285627cd07cc9e3)) * sql injection ([a94e362](https://github.com/frappe/erpnext/commit/a94e362b8ce33144b69afb1fcfc94a261374a551)) * **stock:** add validation for work order seial nos and batch nos ([6d3f9d3](https://github.com/frappe/erpnext/commit/6d3f9d3c6f37930567deb02c20d7649499c18f2e)) * update items respects workflow "Only Allow Edit For" role (backport [#55667](https://github.com/frappe/erpnext/issues/55667)) ([#55705](https://github.com/frappe/erpnext/issues/55705)) ([7852ea6](https://github.com/frappe/erpnext/commit/7852ea65af4ec5208b90d63d60fb7f6be8e9206a)) * use new_doc with field allowlist in CRM integration endpoints ([45b232d](https://github.com/frappe/erpnext/commit/45b232d369eb1624383986d7047d5625c7e4f08f)) * validate fg and materials qty in the disassemble entry ([ba19a24](https://github.com/frappe/erpnext/commit/ba19a245266f280dceb7e9d550d4b7d76e27f26c)) * work order status should be in process if material transfer is skipped (backport [#55641](https://github.com/frappe/erpnext/issues/55641) to version-15-hotfix) ([#55643](https://github.com/frappe/erpnext/issues/55643)) ([55b0715](https://github.com/frappe/erpnext/commit/55b07153104482f1edabe9615821adc3ca95404a)) ### Features * add New Zealand chart of accounts ([f8a123e](https://github.com/frappe/erpnext/commit/f8a123e79dcecdaddb635a55f38af2caf2dfb2bf)) ### Performance Improvements * **transaction:** exit early before backend query (backport [#55556](https://github.com/frappe/erpnext/issues/55556)) ([#55557](https://github.com/frappe/erpnext/issues/55557)) ([ccbca57](https://github.com/frappe/erpnext/commit/ccbca57420b23207c8c0edf7b9e92f0a79f7588c)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 4be8b579505..5b488095239 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.110.0" +__version__ = "15.111.0" def get_default_company(user=None): From c6176500d269cba563b3d00ae5e53ed8f0a5f375 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 10 Jun 2026 10:49:27 +0530 Subject: [PATCH 032/132] 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 033/132] 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 034/132] 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 035/132] 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 036/132] 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 037/132] 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 038/132] 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 039/132] 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 040/132] 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 041/132] 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 042/132] 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 043/132] 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 044/132] 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 045/132] 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 046/132] 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 047/132] 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 048/132] 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 049/132] 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 050/132] 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 051/132] 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 052/132] 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 053/132] 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 054/132] 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 055/132] 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 056/132] 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 057/132] 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 058/132] 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 059/132] 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 060/132] 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 061/132] 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 062/132] 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 063/132] 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 064/132] 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 065/132] 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 066/132] 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 067/132] 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 068/132] 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 069/132] 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 070/132] 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 071/132] 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 072/132] 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 073/132] 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 074/132] 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 075/132] 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 076/132] 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 077/132] 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 078/132] 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 079/132] 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 080/132] 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 081/132] 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 082/132] 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 083/132] 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.""" From e27016339f3c50eacbdc26842ce0f7d9f72cd8b5 Mon Sep 17 00:00:00 2001 From: Frappe PR Bot Date: Tue, 16 Jun 2026 21:53:42 +0000 Subject: [PATCH 084/132] chore(release): Bumped to Version 15.112.0 # [15.112.0](https://github.com/frappe/erpnext/compare/v15.111.0...v15.112.0) (2026-06-16) ### Bug Fixes * **accounts:** removed whitelist on `get_balance_on` (backport [#55956](https://github.com/frappe/erpnext/issues/55956)) ([#55964](https://github.com/frappe/erpnext/issues/55964)) ([64bbc01](https://github.com/frappe/erpnext/commit/64bbc019aded28862892a0e94b16301464404971)) * Add authorization checks on internal functions (backport [#55709](https://github.com/frappe/erpnext/issues/55709)) (backport [#55726](https://github.com/frappe/erpnext/issues/55726)) ([#55736](https://github.com/frappe/erpnext/issues/55736)) ([8c7a313](https://github.com/frappe/erpnext/commit/8c7a313a38dfe38c9e35ca41e91389bcfaed2404)) * added doctype filter validation for sales person wise transaction summary report (backport [#55812](https://github.com/frappe/erpnext/issues/55812)) ([#55817](https://github.com/frappe/erpnext/issues/55817)) ([f1fc9e3](https://github.com/frappe/erpnext/commit/f1fc9e3261a154a9f97c4a3354573c78432e8f33)) * apply user permissions to receivable/payable reports ([c03a66a](https://github.com/frappe/erpnext/commit/c03a66a1bf48a53c42d01c9d936d9b22aa013e11)) * BOM Creator by removing redundant permissions ([08d9b82](https://github.com/frappe/erpnext/commit/08d9b8275daa1763dfc0bb05e56d4929a522b43c)) * **bom:** fetch routing operations when Routing is selected ([#55813](https://github.com/frappe/erpnext/issues/55813)) ([02e38e8](https://github.com/frappe/erpnext/commit/02e38e80a7fe964b03f470dc58e44ff202483f66)) * **budget:** add root_type filter on account field ([#55934](https://github.com/frappe/erpnext/issues/55934)) ([e1fbf78](https://github.com/frappe/erpnext/commit/e1fbf78409d788e34c3e2021eebb0bb0e760596a)) * clearance date update with permission checks ([46b3e0c](https://github.com/frappe/erpnext/commit/46b3e0c385658e1292f1c7c01bfd3fd356f923ad)) * **company:** replaced "this company" with company name on delete transactions dialog (backport [#56021](https://github.com/frappe/erpnext/issues/56021)) ([#56022](https://github.com/frappe/erpnext/issues/56022)) ([8a9461f](https://github.com/frappe/erpnext/commit/8a9461ff45f990a8f4740f3defb34a1ac7407a93)) * converted whitelist non class methods to class methods ([7c78aa6](https://github.com/frappe/erpnext/commit/7c78aa6e5d5ddbd4fd98bab4c741fc30ba556638)) * **get_exchange_rate:** using get_single_value to fetch `disabled` value from `currency_exchange_settings` ([9405b49](https://github.com/frappe/erpnext/commit/9405b49e93b9ce1c04bc3cca78b848325fe7a6dc)) * **Lead:** stop storing Gravatar image URLs for Leads (backport [#55880](https://github.com/frappe/erpnext/issues/55880)) ([#55881](https://github.com/frappe/erpnext/issues/55881)) ([8b3a0fe](https://github.com/frappe/erpnext/commit/8b3a0fe04571c9f48f74bf68fb3ff160269b5151)) * **manufacturing:** fetch raw materials from work order ([7e9c1ef](https://github.com/frappe/erpnext/commit/7e9c1efab72057cd1fad10ef2e027df99ef102e7)) * multiple issues related to BOM Creator ([f4e6f14](https://github.com/frappe/erpnext/commit/f4e6f143421ce92639668c70d2ac1d1aaa500d9e)) * opportunity creation from contact us page (backport [#55841](https://github.com/frappe/erpnext/issues/55841)) ([#55866](https://github.com/frappe/erpnext/issues/55866)) ([d460577](https://github.com/frappe/erpnext/commit/d4605771dae7d9171052cecf271bd3c716bfc93b)) * pass source cost center to target cost center ([209977f](https://github.com/frappe/erpnext/commit/209977f6a30551daa1dfaedbc12deae49c9551a3)) * pemission for whitelist functions ([559c95c](https://github.com/frappe/erpnext/commit/559c95c8a8cb2b41b71c1a86372175fb9fd665d9)) * permission in bom compare tool ([ef3046d](https://github.com/frappe/erpnext/commit/ef3046dca2f3571f5c76cf1e8c82bd7515ce5374)) * permissions in workstation file ([808ca06](https://github.com/frappe/erpnext/commit/808ca06801f0b20dffdb0a4ffc26fecadf2b8ff2)) * prefetch batchwise valuations before streaming SLEs in stock ageing ([c617650](https://github.com/frappe/erpnext/commit/c6176500d269cba563b3d00ae5e53ed8f0a5f375)) * prevent exchange rate flow from transaction to payment ([72c7b79](https://github.com/frappe/erpnext/commit/72c7b79933c2569fa11f329c822316345151fbc5)) * provision to recalculate valuation rate during reposting ([df0c8ee](https://github.com/frappe/erpnext/commit/df0c8ee21eccccbbafd724f8fcb48983e95b741c)) * recalculate incoming rate in SLE for purchase documents during repost ([081887b](https://github.com/frappe/erpnext/commit/081887bec5b7daead28377b9b8b0d7f338d6b2ab)) * regression issues related to security fixes (backport [#55902](https://github.com/frappe/erpnext/issues/55902)) ([#55925](https://github.com/frappe/erpnext/issues/55925)) ([1f47b24](https://github.com/frappe/erpnext/commit/1f47b2417bbaffa59b0d2ebcc84352e539cf682d)) * restricting currency_exchange_settings write permission only to system manager ([7b65206](https://github.com/frappe/erpnext/commit/7b6520664c90c510cb94e98af5172c6d19c77a1e)) * show company name in delete transactions confirmation dialog ([3356583](https://github.com/frappe/erpnext/commit/3356583865708b8ec1bbd595c958b9bef6ac6c79)) * show user disable audit log ([559585f](https://github.com/frappe/erpnext/commit/559585fb7b4f8651d0d799c4059e22f88edddeed)) * **stock:** enable quality inspection for all Stock Entry purposes ([1ffef19](https://github.com/frappe/erpnext/commit/1ffef19957b359619f5a29c6b674286b482c964d)), closes [#55830](https://github.com/frappe/erpnext/issues/55830) [#70945](https://github.com/frappe/erpnext/issues/70945) * **stock:** make uom mandatory in item uom table ([c3a3eb3](https://github.com/frappe/erpnext/commit/c3a3eb3df313f07c2df7c72688fb44f9ca6cc829)) * **stock:** show only batched items in batch item selector ([7f30a2c](https://github.com/frappe/erpnext/commit/7f30a2cfb6d85f0a513935d1db75752e04bc1792)) * **stock:** update stock value calculation in stock balance report ([18ad323](https://github.com/frappe/erpnext/commit/18ad3238286ecf540232f46e9c6ab917c6e74820)) * sync employee user status after save ([e40999c](https://github.com/frappe/erpnext/commit/e40999c8792c542951d6292df0a547cbd1555cb7)) * update system manager permissions ([81e05fc](https://github.com/frappe/erpnext/commit/81e05fc1f61ff8ba0e6168830082edb3c725f902)) * updated role based permission for terms and conditions doctype (backport [#55674](https://github.com/frappe/erpnext/issues/55674)) ([#55675](https://github.com/frappe/erpnext/issues/55675)) ([5f5b2a6](https://github.com/frappe/erpnext/commit/5f5b2a6ae2f5047e9dbfb3e316ccdcbfbc68d9dd)) * use frankfurter v2 by default for new install ([d62985d](https://github.com/frappe/erpnext/commit/d62985d9a76ca474b138a71b97cd9a98d342b7e1)) ### Features * **currency exchange settings:** frankfurter v2 support ([70628c0](https://github.com/frappe/erpnext/commit/70628c06c99d279b59acc5619ab0e77d4077349a)) --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 5b488095239..bfb1c251270 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -4,7 +4,7 @@ import inspect import frappe from frappe.utils.user import is_website_user -__version__ = "15.111.0" +__version__ = "15.112.0" def get_default_company(user=None): From 690adf10518ef1450b994b6a28b35457f926b978 Mon Sep 17 00:00:00 2001 From: S Sakthivel Murugan Date: Wed, 17 Jun 2026 12:51:35 +0530 Subject: [PATCH 085/132] fix: add dynamic links for customer and supplier dashboards --- erpnext/buying/doctype/supplier/supplier_dashboard.py | 1 + erpnext/selling/doctype/customer/customer_dashboard.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/buying/doctype/supplier/supplier_dashboard.py b/erpnext/buying/doctype/supplier/supplier_dashboard.py index 9f424dd0c7c..198b32cda79 100644 --- a/erpnext/buying/doctype/supplier/supplier_dashboard.py +++ b/erpnext/buying/doctype/supplier/supplier_dashboard.py @@ -5,6 +5,7 @@ def get_data(): return { "fieldname": "supplier", "non_standard_fieldnames": {"Payment Entry": "party", "Bank Account": "party"}, + "dynamic_links": {"party": ["Supplier", "party_type"]}, "transactions": [ {"label": _("Procurement"), "items": ["Request for Quotation", "Supplier Quotation"]}, {"label": _("Orders"), "items": ["Purchase Order", "Purchase Receipt", "Purchase Invoice"]}, diff --git a/erpnext/selling/doctype/customer/customer_dashboard.py b/erpnext/selling/doctype/customer/customer_dashboard.py index fc3c5cf1ab2..8afef385af4 100644 --- a/erpnext/selling/doctype/customer/customer_dashboard.py +++ b/erpnext/selling/doctype/customer/customer_dashboard.py @@ -11,7 +11,10 @@ def get_data(): "Bank Account": "party", "Subscription": "party", }, - "dynamic_links": {"party_name": ["Customer", "quotation_to"]}, + "dynamic_links": { + "party_name": ["Customer", "quotation_to"], + "party": ["Customer", "party_type"], + }, "transactions": [ {"label": _("Pre Sales"), "items": ["Opportunity", "Quotation"]}, {"label": _("Orders"), "items": ["Sales Order", "Delivery Note", "Sales Invoice"]}, From 19913127a73050d91ffe61e966e0b655515ca63e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 17 Jun 2026 15:55:22 +0530 Subject: [PATCH 086/132] fix: preserve stock ageing on non-serial reconciliation --- .../stock/report/stock_ageing/stock_ageing.py | 3 +- .../report/stock_ageing/test_stock_ageing.py | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index d0e96d640fe..2e11fa1664b 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -358,7 +358,7 @@ class FIFOSlots: if row.voucher_type != "Stock Reconciliation": return - if not row.batch_no or row.serial_no or row.serial_and_batch_bundle: + if row.has_serial_no and (not row.batch_no or row.serial_no or row.serial_and_batch_bundle): if row.voucher_detail_no in self.stock_reco_voucher_wise_count: # Legacy reconciliation with a single SLE has qty_after_transaction and # stock_value_difference without an outward entry, so reset the queue first. @@ -1083,6 +1083,7 @@ class FIFOSlots: (doctype.voucher_type == "Stock Reconciliation") & (doctype.docstatus < 2) & (doctype.is_cancelled == 0) + & (item.has_serial_no == 1) ) .groupby(doctype.voucher_detail_no) ) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 59550f07aac..003d1a51d93 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -195,6 +195,67 @@ class TestStockAgeing(FrappeTestCase): self.assertEqual(queue[0][0], 20.0) self.assertEqual(queue[1][0], 20.0) + def test_non_serial_stock_reco_decrease_preserves_ageing(self): + """ + Non-serial stock reconciliation should adjust FIFO by the balance delta. + Decreasing stock consumes old slots; increasing stock adds only the new qty. + """ + + def make_sle( + posting_date, + voucher_type, + voucher_no, + actual_qty, + qty_after, + voucher_detail_no=None, + stock_value_difference=None, + ): + stock_value_difference = actual_qty if stock_value_difference is None else stock_value_difference + + return frappe._dict( + name="Flask Item", + item_name="Flask Item", + description="Flask Item", + item_group=None, + brand=None, + stock_uom="Nos", + actual_qty=actual_qty, + qty_after_transaction=qty_after, + stock_value_difference=stock_value_difference, + valuation_rate=1, + warehouse="WH 1", + posting_date=posting_date, + voucher_type=voucher_type, + voucher_no=voucher_no, + voucher_detail_no=voucher_detail_no, + has_serial_no=False, + has_batch_no=False, + serial_no=None, + batch_no=None, + serial_and_batch_bundle=None, + ) + + filters = frappe._dict(company="_Test Company", to_date="2026-02-15", ranges=["30", "60", "90"]) + sle = [ + make_sle("2025-11-30", "Stock Entry", "001", 100, 100), + make_sle("2025-12-31", "Stock Reconciliation", "002", 0, 60, "SRI-DECREASE", -40), + make_sle("2026-01-31", "Stock Reconciliation", "003", 0, 90, "SRI-INCREASE", 30), + ] + + fifo_slots = FIFOSlots(filters, sle) + + def prepare_stock_reco_voucher_wise_count(): + fifo_slots.stock_reco_voucher_wise_count = frappe._dict({"SRI-DECREASE": 100, "SRI-INCREASE": 60}) + + fifo_slots.prepare_stock_reco_voucher_wise_count = prepare_stock_reco_voucher_wise_count + + slots = fifo_slots.generate() + queue = slots["Flask Item"]["fifo_queue"] + report_data = format_report_data(filters, slots, filters.to_date) + + self.assertEqual(queue, [[60.0, "2025-11-30", 60.0], [30.0, "2026-01-31", 30.0]]) + self.assertEqual(report_data[0][7:15], [30.0, 30.0, 0.0, 0.0, 60.0, 60.0, 0.0, 0.0]) + def test_sequential_stock_reco_same_warehouse(self): """ Test back to back stock recos (same warehouse). From 3f9a88a5e29382582e06ec818e29ee1442078561 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 17 Jun 2026 17:22:48 +0530 Subject: [PATCH 087/132] fix(stock): update transfer status for mixed transfer flows --- erpnext/stock/doctype/stock_entry/stock_entry.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index aebcc6dffb2..5ee35dbe3e0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -4116,13 +4116,19 @@ def get_batchwise_serial_nos(item_code, row): def get_transferred_qty(material_request): - sed = DocType("Stock Entry Detail") + from pypika import Case + + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + completed_qty = Case().when(se.add_to_transit == 1, sed.transferred_qty).else_(sed.transfer_qty) query = ( frappe.qb.from_(sed) + .inner_join(se) + .on(se.name == sed.parent) .select( Sum(sed.transfer_qty).as_("transfer_qty"), - Sum(sed.transferred_qty).as_("transferred_qty"), + Sum(completed_qty).as_("transferred_qty"), ) .where((sed.material_request == material_request) & (sed.docstatus == 1)) ).run(as_dict=True) From c13567228ed4af3665181581ebb4d90b0e1605fc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 17 Jun 2026 17:22:53 +0530 Subject: [PATCH 088/132] test(stock): validate completed status for mixed transfer methods --- .../material_request/test_material_request.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 2da1861ba21..ac11b2fb7d9 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -995,6 +995,52 @@ class TestMaterialRequest(FrappeTestCase): se.save() se.submit() + def test_mr_status_for_mixed_direct_and_transit_transfer(self): + material_request = make_material_request( + material_request_type="Material Transfer", + item_code="_Test Item Home Desktop 100", + qty=5, + ) + + in_transit_wh = get_in_transit_warehouse(material_request.company) + + # Make stock available + self._insert_stock_entry(20.0, 20.0) + + # Direct Transfer for 3 Qty + direct_transfer = make_stock_entry(material_request.name) + direct_transfer.items[0].update( + { + "qty": 3, + "transfer_qty": 3, + "s_warehouse": "_Test Warehouse 1 - _TC", + } + ) + direct_transfer.save() + direct_transfer.submit() + + # In Transit Transfer for remaining 2 Qty + transit_transfer = make_in_transit_stock_entry(material_request.name, in_transit_wh) + transit_transfer.items[0].update( + { + "qty": 2, + "s_warehouse": "_Test Warehouse 1 - _TC", + } + ) + transit_transfer.save() + transit_transfer.submit() + + # Complete End Transit + end_transit = make_stock_in_entry(transit_transfer.name) + end_transit.save() + end_transit.submit() + + material_request.reload() + + self.assertEqual(material_request.per_ordered, 100) + self.assertEqual(material_request.status, "Transferred") + self.assertEqual(material_request.transfer_status, "Completed") + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): From 27d574dad50d2e44e58a949effbc7700bde11e99 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:04:58 +0200 Subject: [PATCH 089/132] fix(stock): propagate renamed attribute values to variant items --- erpnext/controllers/item_variant.py | 43 +++++++++++++++++++ erpnext/stock/doctype/item/test_item.py | 24 +++++++++++ .../doctype/item_attribute/item_attribute.py | 2 + 3 files changed, 69 insertions(+) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 74b7dfb5c24..42692b7528c 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -129,6 +129,49 @@ def validate_is_incremental(numeric_attribute, attribute, value, item): ) +def get_attribute_value_renames(item_attribute): + """Return old to new attribute value mappings for renamed Item Attribute Value rows.""" + if item_attribute.numeric_values: + return {} + + db_value = item_attribute.get_doc_before_save() + if not db_value: + return {} + + old_values = {d.name: d.attribute_value for d in db_value.item_attribute_values} + renames = {} + + for row in item_attribute.item_attribute_values: + if row.name in old_values and old_values[row.name] != row.attribute_value: + renames[old_values[row.name]] = row.attribute_value + + return renames + + +def update_variant_attribute_values(item_attribute): + """Propagate renamed Item Attribute Values to Item Variant Attribute on variant items.""" + value_map = get_attribute_value_renames(item_attribute) + if not value_map: + return + + item_variant_table = frappe.qb.DocType("Item Variant Attribute") + item_table = frappe.qb.DocType("Item") + + for old_value, new_value in value_map.items(): + ( + frappe.qb.update(item_variant_table) + .join(item_table) + .on(item_table.name == item_variant_table.parent) + .set(item_variant_table.attribute_value, new_value) + .where(item_table.variant_of.isnotnull()) + .where(item_table.variant_of != "") + .where(item_variant_table.attribute == item_attribute.name) + .where(item_variant_table.attribute_value == old_value) + ).run() + + frappe.flags.attribute_values = None + + def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True): allow_rename_attribute_value = frappe.db.get_single_value( "Item Variant Settings", "allow_rename_attribute_value" diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index d5f13e62a5c..91fa8bece72 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -360,6 +360,30 @@ class TestItem(FrappeTestCase): self.assertRaises(InvalidItemAttributeValueError, attribute.save) frappe.db.rollback() + def test_rename_attribute_value_updates_variants(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + + variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.attribute_value = "Larger" + break + + frappe.flags.attribute_values = None + attribute.save() + + self.assertEqual( + frappe.db.get_value( + "Item Variant Attribute", + {"parent": variant.name, "attribute": "Test Size"}, + "attribute_value", + ), + "Larger", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) diff --git a/erpnext/stock/doctype/item_attribute/item_attribute.py b/erpnext/stock/doctype/item_attribute/item_attribute.py index 3b9bcf93288..14d2c6a4f12 100644 --- a/erpnext/stock/doctype/item_attribute/item_attribute.py +++ b/erpnext/stock/doctype/item_attribute/item_attribute.py @@ -9,6 +9,7 @@ from frappe.utils import flt from erpnext.controllers.item_variant import ( InvalidItemAttributeValueError, + update_variant_attribute_values, validate_is_incremental, validate_item_attribute_value, ) @@ -47,6 +48,7 @@ class ItemAttribute(Document): self.validate_duplication() def on_update(self): + update_variant_attribute_values(self) self.validate_exising_items() self.set_enabled_disabled_in_items() From 60f5de7ab80926afb710d7350185ce52e90a883c Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:34:42 +0200 Subject: [PATCH 090/132] test(stock): add cleanup for item attribute value changes in tests --- erpnext/stock/doctype/item/test_item.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 91fa8bece72..b34ce5bd894 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -372,6 +372,17 @@ class TestItem(FrappeTestCase): row.attribute_value = "Larger" break + def restore_test_size_large(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + if row.attribute_value == "Larger": + row.attribute_value = "Large" + break + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_large) + frappe.flags.attribute_values = None attribute.save() From c7acd887425e42e15460d588a1735a7122e48736 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:55:27 +0200 Subject: [PATCH 091/132] fix(stock): update variant attributes on value rename --- erpnext/controllers/item_variant.py | 25 +++++++------ erpnext/stock/doctype/item/test_item.py | 48 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py index 42692b7528c..c2c620950af 100644 --- a/erpnext/controllers/item_variant.py +++ b/erpnext/controllers/item_variant.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _ +from frappe.query_builder import Case from frappe.utils import cstr, flt from erpnext.utilities.product import get_item_codes_by_attributes @@ -156,18 +157,22 @@ def update_variant_attribute_values(item_attribute): item_variant_table = frappe.qb.DocType("Item Variant Attribute") item_table = frappe.qb.DocType("Item") + attribute_value = item_variant_table.attribute_value + attribute_value_case = Case() for old_value, new_value in value_map.items(): - ( - frappe.qb.update(item_variant_table) - .join(item_table) - .on(item_table.name == item_variant_table.parent) - .set(item_variant_table.attribute_value, new_value) - .where(item_table.variant_of.isnotnull()) - .where(item_table.variant_of != "") - .where(item_variant_table.attribute == item_attribute.name) - .where(item_variant_table.attribute_value == old_value) - ).run() + attribute_value_case = attribute_value_case.when(attribute_value == old_value, new_value) + + ( + frappe.qb.update(item_variant_table) + .join(item_table) + .on(item_table.name == item_variant_table.parent) + .set(attribute_value, attribute_value_case.else_(attribute_value)) + .where(item_table.variant_of.isnotnull()) + .where(item_table.variant_of != "") + .where(item_variant_table.attribute == item_attribute.name) + .where(attribute_value.isin(list(value_map))) + ).run() frappe.flags.attribute_values = None diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index b34ce5bd894..8072437a173 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -395,6 +395,54 @@ class TestItem(FrappeTestCase): "Larger", ) + def test_swapped_attribute_value_renames_update_variants(self): + frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) + frappe.delete_doc_if_exists("Item", "_Test Variant Item-S", force=1) + + large_variant = create_variant("_Test Variant Item", {"Test Size": "Large"}) + large_variant.save() + + small_variant = create_variant("_Test Variant Item", {"Test Size": "Small"}) + small_variant.save() + + attribute = frappe.get_doc("Item Attribute", "Test Size") + original_values = {row.name: row.attribute_value for row in attribute.item_attribute_values} + + def restore_test_size_values(): + doc = frappe.get_doc("Item Attribute", "Test Size") + for row in doc.item_attribute_values: + row.attribute_value = original_values[row.name] + frappe.flags.attribute_values = None + doc.save() + + self.addCleanup(restore_test_size_values) + + for row in attribute.item_attribute_values: + if row.attribute_value == "Large": + row.attribute_value = "Small" + elif row.attribute_value == "Small": + row.attribute_value = "Large" + + frappe.flags.attribute_values = None + attribute.save() + + self.assertEqual( + frappe.db.get_value( + "Item Variant Attribute", + {"parent": large_variant.name, "attribute": "Test Size"}, + "attribute_value", + ), + "Small", + ) + self.assertEqual( + frappe.db.get_value( + "Item Variant Attribute", + {"parent": small_variant.name, "attribute": "Test Size"}, + "attribute_value", + ), + "Large", + ) + def test_make_item_variant(self): frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1) From 98caefea8871053e7e3074faa41ecda5474f0f94 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 18 Jun 2026 11:21:13 +0530 Subject: [PATCH 092/132] refactor: posting datetime for SABB (backport #49710 + #50248) (#56072) * refactor: posting datetime for SABB (backport #49710 + #50248) Backport of #49710 "refactor: posting datetime for SABB" to version-15-hotfix, including the regression fix #50248 "fix: missed refactoring code". Replaces Serial and Batch Bundle's `posting_date`/`posting_time` with a single `posting_datetime` field and updates all readers/writers; adds a patch to populate `posting_datetime` from the linked Stock Ledger Entry and drop the now-unused single-column indexes. Adapted to version-15-hotfix where the surrounding stock code had already moved to `posting_datetime` (SLE) and diverged from develop. Co-Authored-By: Claude Opus 4.8 * chore: fix conflicts * chore: fixed formatting --------- Co-authored-by: Claude Opus 4.8 --- .../controllers/sales_and_purchase_return.py | 5 +- erpnext/controllers/selling_controller.py | 5 +- erpnext/controllers/stock_controller.py | 4 +- .../controllers/subcontracting_controller.py | 4 +- erpnext/patches.txt | 1 + ...ting_datetime_for_sabb_and_drop_indexes.py | 31 ++++ erpnext/stock/deprecated_serial_batch.py | 45 +++--- erpnext/stock/doctype/batch/batch.py | 41 +++--- .../serial_and_batch_bundle.json | 34 ++--- .../serial_and_batch_bundle.py | 134 +++++++----------- .../test_serial_and_batch_bundle.py | 8 +- .../stock/doctype/stock_entry/stock_entry.py | 8 +- .../doctype/stock_entry/stock_entry_utils.py | 8 +- .../stock_reconciliation.py | 10 +- .../serial_and_batch_summary.py | 6 +- erpnext/stock/serial_batch_bundle.py | 55 +++---- erpnext/stock/stock_ledger.py | 8 +- erpnext/stock/utils.py | 24 ++-- 18 files changed, 216 insertions(+), 215 deletions(-) create mode 100644 erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index ef5feafb45c..8f6edbe75ae 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -11,7 +11,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext from erpnext.stock.serial_batch_bundle import get_batches_from_bundle from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle -from erpnext.stock.utils import get_incoming_rate, get_valuation_method, getdate +from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate class StockOverReturnError(frappe.ValidationError): @@ -1180,8 +1180,7 @@ def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_f "batches": data.get("batches"), "serial_nos_valuation": data.get("serial_nos_valuation"), "batches_valuation": data.get("batches_valuation"), - "posting_date": parent_doc.posting_date, - "posting_time": parent_doc.posting_time, + "posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time), "voucher_type": parent_doc.doctype, "voucher_no": parent_doc.name, "voucher_detail_no": child_doc.name, diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 3383bd51fdd..5655fea9915 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -12,7 +12,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.item.item import set_item_default from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor -from erpnext.stock.utils import get_incoming_rate, get_valuation_method +from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method class SellingController(StockController): @@ -1084,8 +1084,7 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None): "voucher_type": parent.doctype, "voucher_no": parent.name if parent.docstatus < 2 else None, "voucher_detail_no": delivery_note_child.name if delivery_note_child else child.name, - "posting_date": parent.posting_date, - "posting_time": parent.posting_time, + "posting_datetime": get_combine_datetime(parent.posting_date, parent.posting_time), "qty": child.qty, "type_of_transaction": "Outward" if child.qty > 0 and parent.docstatus < 2 else "Inward", "company": parent.company, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9e681bce814..32968952fc6 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -26,6 +26,7 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( get_evaluated_inventory_dimension, ) from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_type_of_transaction, ) from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -282,8 +283,7 @@ class StockController(AccountsController): ): bundle_details = { "item_code": row.get("rm_item_code") or row.item_code, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index c32edfaa7b2..6b08cc15838 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -13,6 +13,7 @@ from frappe.utils import cint, flt, get_link_to_form from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, get_available_serial_nos, get_voucher_wise_serial_batch_from_bundle, @@ -570,8 +571,7 @@ class SubcontractingController(StockController): "qty": qty, "serial_nos": serial_nos, "batches": batches, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": "Subcontracting Receipt", "do_not_submit": True, "type_of_transaction": "Outward" if qty > 0 else "Inward", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 239066f7c98..3b0ac5cf552 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -436,3 +436,4 @@ erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item +erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes diff --git a/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py b/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py new file mode 100644 index 00000000000..c4a71791671 --- /dev/null +++ b/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py @@ -0,0 +1,31 @@ +import click +import frappe + + +def execute(): + frappe.db.sql( + """ + UPDATE `tabSerial and Batch Bundle` + JOIN `tabStock Ledger Entry` + ON `tabSerial and Batch Bundle`.`name` = `tabStock Ledger Entry`.`serial_and_batch_bundle` + SET `tabSerial and Batch Bundle`.`posting_datetime` = `tabStock Ledger Entry`.`posting_datetime` + WHERE `tabStock Ledger Entry`.`is_cancelled` = 0 + """ + ) + + drop_indexes() + + +def drop_indexes(): + table = "tabSerial and Batch Bundle" + index_list = ["voucher_no_index", "item_code_index", "warehouse_index", "company_index"] + + for index in index_list: + if not frappe.db.has_index(table, index): + continue + + try: + frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`") + click.echo(f"✓ dropped {index} index from {table}") + except Exception: + frappe.log_error("Failed to drop index") diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 00a06e98d2e..b57b683c2d8 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -36,6 +36,11 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 + posting_datetime = self.sle.posting_datetime + + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + for serial_no in serial_nos: sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) if ( @@ -64,10 +69,7 @@ class DeprecatedSerialNoValuation: & (table.serial_and_batch_bundle.isnull()) & (table.actual_qty > 0) & (table.is_cancelled == 0) - & ( - table.posting_datetime - <= get_combine_datetime(self.sle.posting_date, self.sle.posting_time) - ) + & (table.posting_datetime <= posting_datetime) ) .orderby(table.posting_datetime, order=Order.desc) .limit(1) @@ -98,11 +100,8 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") timestamp_condition = None - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if self.sle.posting_datetime: + posting_datetime = self.sle.posting_datetime if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -202,7 +201,11 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") batch = frappe.qb.DocType("Batch") - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + posting_datetime = self.sle.posting_datetime + + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -266,7 +269,10 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + posting_datetime = self.sle.posting_datetime + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -308,19 +314,22 @@ class DeprecatedBatchNoValuation: @deprecated def set_balance_value_from_bundle(self) -> None: + from erpnext.stock.utils import get_combine_datetime + bundle = frappe.qb.DocType("Serial and Batch Bundle") bundle_child = frappe.qb.DocType("Serial and Batch Entry") batch = frappe.qb.DocType("Batch") - timestamp_condition = CombineDatetime(bundle.posting_date, bundle.posting_time) < CombineDatetime( - self.sle.posting_date, self.sle.posting_time - ) + posting_datetime = self.sle.posting_datetime + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + + timestamp_condition = bundle.posting_datetime < posting_datetime if self.sle.creation: - timestamp_condition |= ( - CombineDatetime(bundle.posting_date, bundle.posting_time) - == CombineDatetime(self.sle.posting_date, self.sle.posting_time) - ) & (bundle.creation < self.sle.creation) + timestamp_condition |= (bundle.posting_datetime == posting_datetime) & ( + bundle.creation < self.sle.creation + ) query = ( frappe.qb.from_(bundle) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 6b2b1360cf9..7f31c65dfcf 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt +import datetime from collections import OrderedDict, defaultdict import frappe @@ -10,7 +11,7 @@ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last from frappe.query_builder.functions import CurDate, Sum from frappe.utils import cint, flt, get_link_to_form -from frappe.utils.data import add_days +from frappe.utils.data import DateTimeLikeObject, add_days class UnableToSelectBatchError(frappe.ValidationError): @@ -232,17 +233,18 @@ class Batch(Document): @frappe.whitelist() def get_batch_qty( - batch_no=None, - warehouse=None, - item_code=None, - creation=None, - posting_date=None, - posting_time=None, - ignore_voucher_nos=None, - for_stock_levels=False, - consider_negative_batches=False, - do_not_check_future_batches=False, - ignore_reserved_stock=False, + batch_no: str | None = None, + warehouse: str | None = None, + item_code: str | None = None, + creation: DateTimeLikeObject | None = None, + posting_datetime: DateTimeLikeObject | None = None, + posting_date: DateTimeLikeObject | None = None, + posting_time: datetime.timedelta | None = None, + ignore_voucher_nos: list | None = None, + for_stock_levels: bool = False, + consider_negative_batches: bool = False, + do_not_check_future_batches: bool = False, + ignore_reserved_stock: bool = False, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -255,6 +257,7 @@ def get_batch_qty( :param for_stock_levels: True consider expired batches""" from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, ) @@ -264,8 +267,6 @@ def get_batch_qty( "item_code": item_code, "warehouse": warehouse, "creation": creation, - "posting_date": posting_date, - "posting_time": posting_time, "batch_no": batch_no, "based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), "ignore_voucher_nos": ignore_voucher_nos, @@ -276,6 +277,10 @@ def get_batch_qty( } ) + kwargs["posting_datetime"] = posting_datetime + if not kwargs.get("posting_datetime") and posting_date: + kwargs["posting_datetime"] = combine_datetime(posting_date, posting_time) + batches = get_auto_batch_nos(kwargs) if not (batch_no and warehouse): @@ -357,6 +362,7 @@ def make_batch_bundle( ): from frappe.utils import nowtime, today + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import combine_datetime from erpnext.stock.serial_batch_bundle import SerialBatchCreation return ( @@ -364,8 +370,7 @@ def make_batch_bundle( { "item_code": item_code, "warehouse": warehouse, - "posting_date": today(), - "posting_time": nowtime(), + "posting_datetime": combine_datetime(today(), nowtime()), "voucher_type": "Stock Entry", "qty": qty, "type_of_transaction": type_of_transaction, @@ -476,9 +481,13 @@ def get_pos_reserved_batch_qty(filters): def get_available_batches(kwargs): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, ) + if kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + batchwise_qty = OrderedDict() batches = get_auto_batch_nos(kwargs) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json index 0a24bfb5645..864a0c51008 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json @@ -29,8 +29,7 @@ "voucher_no", "voucher_detail_no", "column_break_aouy", - "posting_date", - "posting_time", + "posting_datetime", "returned_against", "section_break_wzou", "is_cancelled", @@ -49,8 +48,7 @@ "in_list_view": 1, "label": "Company", "options": "Company", - "reqd": 1, - "search_index": 1 + "reqd": 1 }, { "fetch_from": "item_code.item_group", @@ -79,8 +77,7 @@ "in_standard_filter": 1, "label": "Item Code", "options": "Item", - "reqd": 1, - "search_index": 1 + "reqd": 1 }, { "fetch_from": "item_code.item_name", @@ -117,8 +114,7 @@ "in_standard_filter": 1, "label": "Voucher No", "no_copy": 1, - "options": "voucher_type", - "search_index": 1 + "options": "voucher_type" }, { "default": "0", @@ -188,8 +184,7 @@ "in_standard_filter": 1, "label": "Warehouse", "mandatory_depends_on": "eval:doc.type_of_transaction != \"Maintenance\"", - "options": "Warehouse", - "search_index": 1 + "options": "Warehouse" }, { "fieldname": "type_of_transaction", @@ -211,18 +206,6 @@ "fieldname": "section_break_wzou", "fieldtype": "Section Break" }, - { - "fieldname": "posting_date", - "fieldtype": "Date", - "label": "Posting Date", - "no_copy": 1 - }, - { - "fieldname": "posting_time", - "fieldtype": "Time", - "label": "Posting Time", - "no_copy": 1 - }, { "fieldname": "voucher_detail_no", "fieldtype": "Data", @@ -251,12 +234,17 @@ "label": "Naming Series", "options": "\nSABB-.########", "set_only_once": 1 + }, + { + "fieldname": "posting_datetime", + "fieldtype": "Datetime", + "label": "Posting Datetime" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-09-15 14:37:26.441742", + "modified": "2025-09-24 16:24:48.154853", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Bundle", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 39e5f1425f0..ce185e40a31 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -74,8 +74,7 @@ class SerialandBatchBundle(Document): item_group: DF.Link | None item_name: DF.Data | None naming_series: DF.Literal["", "SABB-.########"] - posting_date: DF.Date | None - posting_time: DF.Time | None + posting_datetime: DF.Datetime | None returned_against: DF.Data | None total_amount: DF.Float total_qty: DF.Float @@ -280,8 +279,7 @@ class SerialandBatchBundle(Document): kwargs.update( { "voucher_no": self.voucher_no, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, } ) @@ -332,8 +330,7 @@ class SerialandBatchBundle(Document): kwargs = frappe._dict( { "item_code": self.item_code, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, "serial_nos": serial_nos, "check_serial_nos": True, } @@ -669,8 +666,7 @@ class SerialandBatchBundle(Document): def get_sle_for_outward_transaction(self): sle = frappe._dict( { - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, "item_code": self.item_code, "warehouse": self.warehouse, "serial_and_batch_bundle": self.name, @@ -829,11 +825,10 @@ class SerialandBatchBundle(Document): if not self.voucher_detail_no or self.voucher_detail_no != row.name: values_to_set["voucher_detail_no"] = row.name - if parent.get("posting_date") and (not self.posting_date or self.posting_date != parent.posting_date): - values_to_set["posting_date"] = parent.posting_date or today() - - if parent.get("posting_time") and (not self.posting_time or self.posting_time != parent.posting_time): - values_to_set["posting_time"] = parent.posting_time + if parent.get("posting_date") and parent.get("posting_time"): + posting_datetime = combine_datetime(parent.posting_date, parent.posting_time) + if not self.posting_datetime or self.posting_datetime != posting_datetime: + values_to_set["posting_datetime"] = posting_datetime if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"): values_to_set["voucher_detail_no"] = row.get("parent_detail_docname") @@ -911,9 +906,7 @@ class SerialandBatchBundle(Document): parent = frappe.qb.DocType("Serial and Batch Bundle") child = frappe.qb.DocType("Serial and Batch Entry") - timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) > CombineDatetime( - self.posting_date, self.posting_time - ) + timestamp_condition = parent.posting_datetime > self.posting_datetime future_entries = ( frappe.qb.from_(parent) @@ -2083,6 +2076,8 @@ def create_serial_batch_no_ledgers( if parent_doc.get("doctype") == "Stock Entry": warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse + posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc = frappe.get_doc( { "doctype": "Serial and Batch Bundle", @@ -2091,8 +2086,7 @@ def create_serial_batch_no_ledgers( "warehouse": warehouse, "is_rejected": child_row.is_rejected, "type_of_transaction": type_of_transaction, - "posting_date": parent_doc.get("posting_date"), - "posting_time": parent_doc.get("posting_time"), + "posting_datetime": posting_datetime, "company": parent_doc.get("company"), } ) @@ -2128,6 +2122,12 @@ def create_serial_batch_no_ledgers( return doc +def combine_datetime(date, time=None): + from erpnext.stock.utils import get_combine_datetime + + return get_combine_datetime(date, time) + + def get_batch(item_code): from erpnext.stock.doctype.batch.batch import make_batch @@ -2180,8 +2180,8 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh ) doc.voucher_detail_no = child_row.name - doc.posting_date = parent_doc.posting_date - doc.posting_time = parent_doc.posting_time + doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc.warehouse = warehouse or doc.warehouse doc.set("entries", []) @@ -2269,6 +2269,9 @@ def get_available_serial_nos(kwargs): elif kwargs.based_on == "Expiry": order_by = "amc_expiry_date" + if not kwargs.get("posting_datetime") and kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + filters = {"item_code": kwargs.item_code} # ignore_warehouse is used for backdated stock transactions @@ -2285,10 +2288,8 @@ def get_available_serial_nos(kwargs): if kwargs.get("ignore_serial_nos"): ignore_serial_nos.extend(kwargs.get("ignore_serial_nos")) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - + ignore_serial_nos = list(set(ignore_serial_nos)) + if kwargs.get("posting_datetime"): time_based_serial_nos = get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos) if not time_based_serial_nos: @@ -2692,6 +2693,9 @@ def get_reserved_batches_for_sre(kwargs) -> dict: def get_auto_batch_nos(kwargs): + if not kwargs.get("posting_datetime") and kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + available_batches = get_available_batches(kwargs) qty = flt(kwargs.qty) @@ -2721,7 +2725,7 @@ def get_auto_batch_nos(kwargs): if kwargs.based_on == "Expiry": available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31")) - if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_date"): + if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_datetime"): filter_zero_near_batches(available_batches, kwargs) if not kwargs.consider_negative_batches: @@ -2737,8 +2741,7 @@ def get_auto_batch_nos(kwargs): def filter_zero_near_batches(available_batches, kwargs): kwargs.batch_no = [d.batch_no for d in available_batches] - del kwargs["posting_date"] - del kwargs["posting_time"] + del kwargs["posting_datetime"] kwargs.do_not_check_future_batches = 1 available_batches_in_future = get_auto_batch_nos(kwargs) @@ -2804,8 +2807,6 @@ def update_available_batches(available_batches, *reserved_batches) -> None: def get_available_batches(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") batch_ledger = frappe.qb.DocType("Serial and Batch Entry") batch_table = frappe.qb.DocType("Batch") @@ -2833,23 +2834,15 @@ def get_available_batches(kwargs): if not kwargs.get("for_stock_levels"): query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3035,15 +3028,14 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: serial_batch_table.incoming_rate, bundle_table.voucher_detail_no, bundle_table.voucher_no, - bundle_table.posting_date, - bundle_table.posting_time, + bundle_table.posting_datetime, ) .where( (bundle_table.docstatus == 1) & (bundle_table.is_cancelled == 0) & (bundle_table.type_of_transaction.isin(["Inward", "Outward"])) ) - .orderby(bundle_table.posting_date, bundle_table.posting_time) + .orderby(bundle_table.posting_datetime) ) for key, val in kwargs.items(): @@ -3061,7 +3053,7 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: query = query.where(bundle_table[key].isin(val)) else: query = query.where(bundle_table[key] == val) - elif key in ["posting_date", "posting_time"]: + elif key in ["posting_datetime"]: query = query.where(bundle_table[key] >= val) else: if isinstance(val, list): @@ -3073,8 +3065,6 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: def get_stock_ledgers_for_serial_nos(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -3090,23 +3080,15 @@ def get_stock_ledgers_for_serial_nos(kwargs): .orderby(stock_ledger_entry.creation) ) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3129,8 +3111,6 @@ def get_stock_ledgers_for_serial_nos(kwargs): def get_stock_ledgers_batches(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") batch_table = frappe.qb.DocType("Batch") @@ -3164,23 +3144,15 @@ def get_stock_ledgers_batches(kwargs): if not kwargs.get("for_stock_levels"): query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3268,3 +3240,7 @@ def get_stock_reco_details(voucher_detail_no): ], as_dict=True, ) + + +def on_doctype_update(): + frappe.db.add_index("Serial and Batch Bundle", ["item_code", "warehouse", "posting_datetime", "creation"]) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2c184cbb876..2cee2bb6f0a 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -10,6 +10,7 @@ from frappe.utils import flt, nowtime, today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, + combine_datetime, make_batch_nos, make_serial_nos, ) @@ -1271,14 +1272,17 @@ def make_serial_batch_bundle(kwargs): if kwargs.get("type_of_transaction"): type_of_transaction = kwargs.get("type_of_transaction") + posting_datetime = None + if kwargs.get("posting_date"): + posting_datetime = combine_datetime(kwargs.posting_date, kwargs.posting_time or nowtime()) + sb = SerialBatchCreation( { "item_code": kwargs.item_code, "warehouse": kwargs.warehouse, "voucher_type": kwargs.voucher_type, "voucher_no": kwargs.voucher_no, - "posting_date": kwargs.posting_date, - "posting_time": kwargs.posting_time, + "posting_datetime": posting_datetime, "qty": kwargs.qty, "avg_rate": kwargs.rate, "batches": kwargs.batches, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 5ee35dbe3e0..3ed696e0a02 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -52,7 +52,7 @@ from erpnext.stock.serial_batch_bundle import ( get_serial_or_batch_items, ) from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate -from erpnext.stock.utils import get_bin, get_incoming_rate +from erpnext.stock.utils import get_bin, get_combine_datetime, get_incoming_rate class FinishedGoodError(frappe.ValidationError): @@ -1527,8 +1527,7 @@ class StockEntry(StockController): { "item_code": row.item_code, "warehouse": row.s_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_detail_no": row.name, "qty": row.transfer_qty * -1, @@ -4058,8 +4057,7 @@ def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=N "item_code": child.item_code, "warehouse": child.warehouse, "type_of_transaction": type_of_transaction, - "posting_date": parent_doc.posting_date, - "posting_time": parent_doc.posting_time, + "posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time), } ) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index e5f4e73e443..1b4c3d7407f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -8,6 +8,7 @@ import frappe from frappe.utils import cint, flt import erpnext +from erpnext.stock.utils import get_combine_datetime if TYPE_CHECKING: from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry @@ -140,6 +141,10 @@ def make_stock_entry(**args): elif args.batches: batches = args.batches + posting_datetime = None + if args.posting_date and args.posting_time: + posting_datetime = get_combine_datetime(args.posting_date, args.posting_time) + bundle_id = ( SerialBatchCreation( { @@ -151,8 +156,7 @@ def make_stock_entry(**args): "serial_nos": args.serial_no, "type_of_transaction": "Outward" if args.source else "Inward", "company": s.company, - "posting_date": s.posting_date, - "posting_time": s.posting_time, + "posting_datetime": posting_datetime, "rate": args.rate or args.basic_rate, "do_not_submit": True, } diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b3e330a0a85..67f9b57c172 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -13,6 +13,7 @@ from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_available_serial_nos, ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -136,8 +137,7 @@ class StockReconciliation(StockController): { "item_code": row.item_code, "warehouse": row.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, @@ -243,8 +243,7 @@ class StockReconciliation(StockController): "doctype": "Serial and Batch Bundle", "item_code": item.item_code, "warehouse": item.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "type_of_transaction": "Outward", } @@ -262,8 +261,7 @@ class StockReconciliation(StockController): { "item_code": item.item_code, "warehouse": item.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "ignore_warehouse": 1, } ) diff --git a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py index 486828af1cc..f821899e209 100644 --- a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py +++ b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py @@ -19,7 +19,7 @@ def get_data(filters): "Serial and Batch Bundle", fields=[ "`tabSerial and Batch Bundle`.`voucher_type`", - "`tabSerial and Batch Bundle`.`posting_date`", + "`tabSerial and Batch Bundle`.`posting_datetime` as posting_date", "`tabSerial and Batch Bundle`.`name`", "`tabSerial and Batch Bundle`.`company`", "`tabSerial and Batch Bundle`.`voucher_no`", @@ -33,7 +33,7 @@ def get_data(filters): "`tabSerial and Batch Entry`.`qty`", ], filters=filter_conditions, - order_by="posting_date", + order_by="posting_datetime", ) @@ -54,7 +54,7 @@ def get_filter_conditions(filters): filter_conditions.append( [ "Serial and Batch Bundle", - "posting_date", + "posting_datetime", "between", [filters.get("from_date"), filters.get("to_date")], ] diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index b2eba994538..1b030dbe2fa 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -5,7 +5,7 @@ from frappe import _, bold from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series from frappe.query_builder import Case from frappe.query_builder.functions import CombineDatetime, Max, Sum, Timestamp -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, now, nowtime, today +from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today from pypika import Order from pypika.terms import ExistsCriterion @@ -140,8 +140,7 @@ class SerialBatchBundle: { "item_code": self.item_code, "warehouse": self.warehouse, - "posting_date": self.sle.posting_date, - "posting_time": self.sle.posting_time, + "posting_datetime": self.sle.posting_datetime, "voucher_type": self.sle.voucher_type, "voucher_no": self.sle.voucher_no, "voucher_detail_no": self.sle.voucher_detail_no, @@ -484,7 +483,7 @@ class SerialBatchBundle: if status == "Delivered": warranty_period = frappe.get_cached_value("Item", sle.item_code, "warranty_period") if warranty_period: - warranty_expiry_date = add_days(sle.posting_date, cint(warranty_period)) + warranty_expiry_date = add_days(getdate(sle.posting_datetime), cint(warranty_period)) query = query.set(sn_table.warranty_expiry_date, warranty_expiry_date) query = query.set(sn_table.warranty_period, warranty_period) else: @@ -509,7 +508,7 @@ class SerialBatchBundle: sle_doctype.voucher_no, sle_doctype.is_cancelled, sle_doctype.item_code, - sle_doctype.posting_date, + sle_doctype.posting_datetime, sle_doctype.company, ) .where( @@ -663,7 +662,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation): .on(bundle.name == bundle_child.parent) .select( bundle_child.serial_no, - Max(CombineDatetime(bundle.posting_date, bundle.posting_time)).as_("max_posting_dt"), + Max(bundle.posting_datetime).as_("max_posting_dt"), ) .where( (bundle.is_cancelled == 0) @@ -681,13 +680,8 @@ class SerialNoValuation(DeprecatedSerialNoValuation): if self.sle.voucher_no: latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no) - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - timestamp_condition = CombineDatetime( - bundle.posting_date, bundle.posting_time - ) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time) + if self.sle.posting_datetime: + timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime latest_posting = latest_posting.where(timestamp_condition) @@ -704,10 +698,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation): .join(latest_posting) .on( (latest_posting.serial_no == bundle_child.serial_no) - & ( - latest_posting.max_posting_dt - == CombineDatetime(bundle.posting_date, bundle.posting_time) - ) + & (latest_posting.max_posting_dt == bundle.posting_datetime) ) .select( bundle_child.serial_no, @@ -839,19 +830,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation): child = frappe.qb.DocType("Serial and Batch Entry") timestamp_condition = "" - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) < CombineDatetime( - self.sle.posting_date, self.sle.posting_time - ) + if self.sle.posting_datetime: + timestamp_condition = parent.posting_datetime < self.sle.posting_datetime if self.sle.creation: - timestamp_condition |= ( - CombineDatetime(parent.posting_date, parent.posting_time) - == CombineDatetime(self.sle.posting_date, self.sle.posting_time) - ) & (parent.creation < self.sle.creation) + timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & ( + parent.creation < self.sle.creation + ) query = ( frappe.qb.from_(parent) @@ -1074,9 +1059,9 @@ class SerialBatchCreation: self.__dict__.update(item_details) def set_other_details(self): - if not self.get("posting_date"): - self.posting_date = today() - self.__dict__["posting_date"] = self.posting_date + if not self.get("posting_datetime"): + self.posting_datetime = now() + self.__dict__["posting_datetime"] = self.posting_datetime if not self.get("actual_qty"): qty = self.get("qty") or self.get("total_qty") @@ -1101,8 +1086,7 @@ class SerialBatchCreation: new_package.docstatus = 0 new_package.warehouse = self.warehouse new_package.voucher_no = "" - new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today() - new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime() + new_package.posting_datetime = self.posting_datetime if hasattr(self, "posting_datetime") else now() new_package.type_of_transaction = self.type_of_transaction new_package.returned_against = self.get("returned_against") @@ -1242,9 +1226,8 @@ class SerialBatchCreation: elif self.has_serial_no and not self.get("serial_nos"): self.serial_nos = get_serial_nos_for_outward(kwargs) elif not self.has_serial_no and self.has_batch_no and not self.get("batches"): - if self.get("posting_date"): - kwargs["posting_date"] = self.get("posting_date") - kwargs["posting_time"] = self.get("posting_time") + if self.get("posting_datetime"): + kwargs["posting_datetime"] = self.get("posting_datetime") self.batches = get_available_batches(kwargs) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7ae0a2fda49..bcc5b3e8a26 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1975,9 +1975,6 @@ def get_valuation_rate( # Get moving average rate of a specific batch number if warehouse and serial_and_batch_bundle: - sabb = frappe.db.get_value( - "Serial and Batch Bundle", serial_and_batch_bundle, ["posting_date", "posting_time"], as_dict=True - ) batch_obj = BatchNoValuation( sle=frappe._dict( { @@ -1985,8 +1982,9 @@ def get_valuation_rate( "warehouse": warehouse, "actual_qty": -1, "serial_and_batch_bundle": serial_and_batch_bundle, - "posting_date": sabb.posting_date, - "posting_time": sabb.posting_time, + "posting_datetime": frappe.get_value( + "Serial and Batch Bundle", serial_and_batch_bundle, "posting_datetime" + ), } ) ) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 8dfcf5a833e..92a42ec714c 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -2,12 +2,14 @@ # License: GNU General Public License v3. See license.txt +import datetime import json import frappe from frappe import _ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime +from frappe.utils.data import DateTimeLikeObject import erpnext from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions @@ -94,13 +96,13 @@ def get_stock_value_on( @frappe.whitelist() def get_stock_balance( - item_code, - warehouse, - posting_date=None, - posting_time=None, - with_valuation_rate=False, - with_serial_no=False, - inventory_dimensions_dict=None, + item_code: str, + warehouse: str | None, + posting_date: DateTimeLikeObject | None = None, + posting_time: DateTimeLikeObject | datetime.timedelta | None = None, + with_valuation_rate: bool = False, + with_serial_no: bool = False, + inventory_dimensions_dict: dict | None = None, ): """Returns stock balance quantity at given warehouse on given posting date or current date. @@ -146,8 +148,7 @@ def get_stock_balance( { "item_code": item_code, "warehouse": warehouse, - "posting_date": posting_date, - "posting_time": posting_time, + "posting_datetime": get_combine_datetime(posting_date, posting_time), "ignore_warehouse": 1, } ) @@ -247,13 +248,16 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() -def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True): +def get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fallbacks: bool = True): """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate if isinstance(args, str): args = json.loads(args) + if not args.get("posting_datetime") and args.get("posting_date"): + args["posting_datetime"] = get_combine_datetime(args.get("posting_date"), args.get("posting_time")) + in_rate = None item_details = frappe.get_cached_value( From 144483765323f4197085bf183ff973a533d70269 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Thu, 18 Jun 2026 11:21:13 +0530 Subject: [PATCH 093/132] refactor: posting datetime for SABB (backport #49710 + #50248) (#56072) * refactor: posting datetime for SABB (backport #49710 + #50248) Backport of #49710 "refactor: posting datetime for SABB" to version-15-hotfix, including the regression fix #50248 "fix: missed refactoring code". Replaces Serial and Batch Bundle's `posting_date`/`posting_time` with a single `posting_datetime` field and updates all readers/writers; adds a patch to populate `posting_datetime` from the linked Stock Ledger Entry and drop the now-unused single-column indexes. Adapted to version-15-hotfix where the surrounding stock code had already moved to `posting_datetime` (SLE) and diverged from develop. Co-Authored-By: Claude Opus 4.8 * chore: fix conflicts * chore: fixed formatting --------- Co-authored-by: Claude Opus 4.8 (cherry picked from commit 98caefea8871053e7e3074faa41ecda5474f0f94) --- .../controllers/sales_and_purchase_return.py | 5 +- erpnext/controllers/selling_controller.py | 5 +- erpnext/controllers/stock_controller.py | 4 +- .../controllers/subcontracting_controller.py | 4 +- erpnext/patches.txt | 1 + ...ting_datetime_for_sabb_and_drop_indexes.py | 31 ++++ erpnext/stock/deprecated_serial_batch.py | 45 +++--- erpnext/stock/doctype/batch/batch.py | 41 +++--- .../serial_and_batch_bundle.json | 34 ++--- .../serial_and_batch_bundle.py | 134 +++++++----------- .../test_serial_and_batch_bundle.py | 8 +- .../stock/doctype/stock_entry/stock_entry.py | 8 +- .../doctype/stock_entry/stock_entry_utils.py | 8 +- .../stock_reconciliation.py | 10 +- .../serial_and_batch_summary.py | 6 +- erpnext/stock/serial_batch_bundle.py | 55 +++---- erpnext/stock/stock_ledger.py | 8 +- erpnext/stock/utils.py | 24 ++-- 18 files changed, 216 insertions(+), 215 deletions(-) create mode 100644 erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index ef5feafb45c..8f6edbe75ae 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -11,7 +11,7 @@ from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext from erpnext.stock.serial_batch_bundle import get_batches_from_bundle from erpnext.stock.serial_batch_bundle import get_serial_nos as get_serial_nos_from_bundle -from erpnext.stock.utils import get_incoming_rate, get_valuation_method, getdate +from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method, getdate class StockOverReturnError(frappe.ValidationError): @@ -1180,8 +1180,7 @@ def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_f "batches": data.get("batches"), "serial_nos_valuation": data.get("serial_nos_valuation"), "batches_valuation": data.get("batches_valuation"), - "posting_date": parent_doc.posting_date, - "posting_time": parent_doc.posting_time, + "posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time), "voucher_type": parent_doc.doctype, "voucher_no": parent_doc.name, "voucher_detail_no": child_doc.name, diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 3383bd51fdd..5655fea9915 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -12,7 +12,7 @@ from erpnext.controllers.sales_and_purchase_return import get_rate_for_return, i from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.item.item import set_item_default from erpnext.stock.get_item_details import get_bin_details, get_conversion_factor -from erpnext.stock.utils import get_incoming_rate, get_valuation_method +from erpnext.stock.utils import get_combine_datetime, get_incoming_rate, get_valuation_method class SellingController(StockController): @@ -1084,8 +1084,7 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None): "voucher_type": parent.doctype, "voucher_no": parent.name if parent.docstatus < 2 else None, "voucher_detail_no": delivery_note_child.name if delivery_note_child else child.name, - "posting_date": parent.posting_date, - "posting_time": parent.posting_time, + "posting_datetime": get_combine_datetime(parent.posting_date, parent.posting_time), "qty": child.qty, "type_of_transaction": "Outward" if child.qty > 0 and parent.docstatus < 2 else "Inward", "company": parent.company, diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 9e681bce814..32968952fc6 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -26,6 +26,7 @@ from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( get_evaluated_inventory_dimension, ) from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_type_of_transaction, ) from erpnext.stock.stock_ledger import get_items_to_be_repost @@ -282,8 +283,7 @@ class StockController(AccountsController): ): bundle_details = { "item_code": row.get("rm_item_code") or row.item_code, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index c32edfaa7b2..6b08cc15838 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -13,6 +13,7 @@ from frappe.utils import cint, flt, get_link_to_form from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, get_available_serial_nos, get_voucher_wise_serial_batch_from_bundle, @@ -570,8 +571,7 @@ class SubcontractingController(StockController): "qty": qty, "serial_nos": serial_nos, "batches": batches, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": "Subcontracting Receipt", "do_not_submit": True, "type_of_transaction": "Outward" if qty > 0 else "Inward", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 239066f7c98..3b0ac5cf552 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -436,3 +436,4 @@ erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item +erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes diff --git a/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py b/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py new file mode 100644 index 00000000000..c4a71791671 --- /dev/null +++ b/erpnext/patches/v16_0/set_posting_datetime_for_sabb_and_drop_indexes.py @@ -0,0 +1,31 @@ +import click +import frappe + + +def execute(): + frappe.db.sql( + """ + UPDATE `tabSerial and Batch Bundle` + JOIN `tabStock Ledger Entry` + ON `tabSerial and Batch Bundle`.`name` = `tabStock Ledger Entry`.`serial_and_batch_bundle` + SET `tabSerial and Batch Bundle`.`posting_datetime` = `tabStock Ledger Entry`.`posting_datetime` + WHERE `tabStock Ledger Entry`.`is_cancelled` = 0 + """ + ) + + drop_indexes() + + +def drop_indexes(): + table = "tabSerial and Batch Bundle" + index_list = ["voucher_no_index", "item_code_index", "warehouse_index", "company_index"] + + for index in index_list: + if not frappe.db.has_index(table, index): + continue + + try: + frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`") + click.echo(f"✓ dropped {index} index from {table}") + except Exception: + frappe.log_error("Failed to drop index") diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 00a06e98d2e..b57b683c2d8 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -36,6 +36,11 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 + posting_datetime = self.sle.posting_datetime + + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + for serial_no in serial_nos: sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) if ( @@ -64,10 +69,7 @@ class DeprecatedSerialNoValuation: & (table.serial_and_batch_bundle.isnull()) & (table.actual_qty > 0) & (table.is_cancelled == 0) - & ( - table.posting_datetime - <= get_combine_datetime(self.sle.posting_date, self.sle.posting_time) - ) + & (table.posting_datetime <= posting_datetime) ) .orderby(table.posting_datetime, order=Order.desc) .limit(1) @@ -98,11 +100,8 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") timestamp_condition = None - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if self.sle.posting_datetime: + posting_datetime = self.sle.posting_datetime if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -202,7 +201,11 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") batch = frappe.qb.DocType("Batch") - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + posting_datetime = self.sle.posting_datetime + + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -266,7 +269,10 @@ class DeprecatedBatchNoValuation: sle = frappe.qb.DocType("Stock Ledger Entry") - posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + posting_datetime = self.sle.posting_datetime + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + if not self.sle.creation: posting_datetime = posting_datetime + datetime.timedelta(milliseconds=1) @@ -308,19 +314,22 @@ class DeprecatedBatchNoValuation: @deprecated def set_balance_value_from_bundle(self) -> None: + from erpnext.stock.utils import get_combine_datetime + bundle = frappe.qb.DocType("Serial and Batch Bundle") bundle_child = frappe.qb.DocType("Serial and Batch Entry") batch = frappe.qb.DocType("Batch") - timestamp_condition = CombineDatetime(bundle.posting_date, bundle.posting_time) < CombineDatetime( - self.sle.posting_date, self.sle.posting_time - ) + posting_datetime = self.sle.posting_datetime + if not posting_datetime and self.sle.posting_date: + posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + + timestamp_condition = bundle.posting_datetime < posting_datetime if self.sle.creation: - timestamp_condition |= ( - CombineDatetime(bundle.posting_date, bundle.posting_time) - == CombineDatetime(self.sle.posting_date, self.sle.posting_time) - ) & (bundle.creation < self.sle.creation) + timestamp_condition |= (bundle.posting_datetime == posting_datetime) & ( + bundle.creation < self.sle.creation + ) query = ( frappe.qb.from_(bundle) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 6b2b1360cf9..7f31c65dfcf 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt +import datetime from collections import OrderedDict, defaultdict import frappe @@ -10,7 +11,7 @@ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last from frappe.query_builder.functions import CurDate, Sum from frappe.utils import cint, flt, get_link_to_form -from frappe.utils.data import add_days +from frappe.utils.data import DateTimeLikeObject, add_days class UnableToSelectBatchError(frappe.ValidationError): @@ -232,17 +233,18 @@ class Batch(Document): @frappe.whitelist() def get_batch_qty( - batch_no=None, - warehouse=None, - item_code=None, - creation=None, - posting_date=None, - posting_time=None, - ignore_voucher_nos=None, - for_stock_levels=False, - consider_negative_batches=False, - do_not_check_future_batches=False, - ignore_reserved_stock=False, + batch_no: str | None = None, + warehouse: str | None = None, + item_code: str | None = None, + creation: DateTimeLikeObject | None = None, + posting_datetime: DateTimeLikeObject | None = None, + posting_date: DateTimeLikeObject | None = None, + posting_time: datetime.timedelta | None = None, + ignore_voucher_nos: list | None = None, + for_stock_levels: bool = False, + consider_negative_batches: bool = False, + do_not_check_future_batches: bool = False, + ignore_reserved_stock: bool = False, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -255,6 +257,7 @@ def get_batch_qty( :param for_stock_levels: True consider expired batches""" from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, ) @@ -264,8 +267,6 @@ def get_batch_qty( "item_code": item_code, "warehouse": warehouse, "creation": creation, - "posting_date": posting_date, - "posting_time": posting_time, "batch_no": batch_no, "based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), "ignore_voucher_nos": ignore_voucher_nos, @@ -276,6 +277,10 @@ def get_batch_qty( } ) + kwargs["posting_datetime"] = posting_datetime + if not kwargs.get("posting_datetime") and posting_date: + kwargs["posting_datetime"] = combine_datetime(posting_date, posting_time) + batches = get_auto_batch_nos(kwargs) if not (batch_no and warehouse): @@ -357,6 +362,7 @@ def make_batch_bundle( ): from frappe.utils import nowtime, today + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import combine_datetime from erpnext.stock.serial_batch_bundle import SerialBatchCreation return ( @@ -364,8 +370,7 @@ def make_batch_bundle( { "item_code": item_code, "warehouse": warehouse, - "posting_date": today(), - "posting_time": nowtime(), + "posting_datetime": combine_datetime(today(), nowtime()), "voucher_type": "Stock Entry", "qty": qty, "type_of_transaction": type_of_transaction, @@ -476,9 +481,13 @@ def get_pos_reserved_batch_qty(filters): def get_available_batches(kwargs): from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_auto_batch_nos, ) + if kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + batchwise_qty = OrderedDict() batches = get_auto_batch_nos(kwargs) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json index 0a24bfb5645..864a0c51008 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json @@ -29,8 +29,7 @@ "voucher_no", "voucher_detail_no", "column_break_aouy", - "posting_date", - "posting_time", + "posting_datetime", "returned_against", "section_break_wzou", "is_cancelled", @@ -49,8 +48,7 @@ "in_list_view": 1, "label": "Company", "options": "Company", - "reqd": 1, - "search_index": 1 + "reqd": 1 }, { "fetch_from": "item_code.item_group", @@ -79,8 +77,7 @@ "in_standard_filter": 1, "label": "Item Code", "options": "Item", - "reqd": 1, - "search_index": 1 + "reqd": 1 }, { "fetch_from": "item_code.item_name", @@ -117,8 +114,7 @@ "in_standard_filter": 1, "label": "Voucher No", "no_copy": 1, - "options": "voucher_type", - "search_index": 1 + "options": "voucher_type" }, { "default": "0", @@ -188,8 +184,7 @@ "in_standard_filter": 1, "label": "Warehouse", "mandatory_depends_on": "eval:doc.type_of_transaction != \"Maintenance\"", - "options": "Warehouse", - "search_index": 1 + "options": "Warehouse" }, { "fieldname": "type_of_transaction", @@ -211,18 +206,6 @@ "fieldname": "section_break_wzou", "fieldtype": "Section Break" }, - { - "fieldname": "posting_date", - "fieldtype": "Date", - "label": "Posting Date", - "no_copy": 1 - }, - { - "fieldname": "posting_time", - "fieldtype": "Time", - "label": "Posting Time", - "no_copy": 1 - }, { "fieldname": "voucher_detail_no", "fieldtype": "Data", @@ -251,12 +234,17 @@ "label": "Naming Series", "options": "\nSABB-.########", "set_only_once": 1 + }, + { + "fieldname": "posting_datetime", + "fieldtype": "Datetime", + "label": "Posting Datetime" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-09-15 14:37:26.441742", + "modified": "2025-09-24 16:24:48.154853", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Bundle", diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 39e5f1425f0..ce185e40a31 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -74,8 +74,7 @@ class SerialandBatchBundle(Document): item_group: DF.Link | None item_name: DF.Data | None naming_series: DF.Literal["", "SABB-.########"] - posting_date: DF.Date | None - posting_time: DF.Time | None + posting_datetime: DF.Datetime | None returned_against: DF.Data | None total_amount: DF.Float total_qty: DF.Float @@ -280,8 +279,7 @@ class SerialandBatchBundle(Document): kwargs.update( { "voucher_no": self.voucher_no, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, } ) @@ -332,8 +330,7 @@ class SerialandBatchBundle(Document): kwargs = frappe._dict( { "item_code": self.item_code, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, "serial_nos": serial_nos, "check_serial_nos": True, } @@ -669,8 +666,7 @@ class SerialandBatchBundle(Document): def get_sle_for_outward_transaction(self): sle = frappe._dict( { - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": self.posting_datetime, "item_code": self.item_code, "warehouse": self.warehouse, "serial_and_batch_bundle": self.name, @@ -829,11 +825,10 @@ class SerialandBatchBundle(Document): if not self.voucher_detail_no or self.voucher_detail_no != row.name: values_to_set["voucher_detail_no"] = row.name - if parent.get("posting_date") and (not self.posting_date or self.posting_date != parent.posting_date): - values_to_set["posting_date"] = parent.posting_date or today() - - if parent.get("posting_time") and (not self.posting_time or self.posting_time != parent.posting_time): - values_to_set["posting_time"] = parent.posting_time + if parent.get("posting_date") and parent.get("posting_time"): + posting_datetime = combine_datetime(parent.posting_date, parent.posting_time) + if not self.posting_datetime or self.posting_datetime != posting_datetime: + values_to_set["posting_datetime"] = posting_datetime if row.get("doctype") == "Packed Item" and row.get("parent_detail_docname"): values_to_set["voucher_detail_no"] = row.get("parent_detail_docname") @@ -911,9 +906,7 @@ class SerialandBatchBundle(Document): parent = frappe.qb.DocType("Serial and Batch Bundle") child = frappe.qb.DocType("Serial and Batch Entry") - timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) > CombineDatetime( - self.posting_date, self.posting_time - ) + timestamp_condition = parent.posting_datetime > self.posting_datetime future_entries = ( frappe.qb.from_(parent) @@ -2083,6 +2076,8 @@ def create_serial_batch_no_ledgers( if parent_doc.get("doctype") == "Stock Entry": warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse + posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc = frappe.get_doc( { "doctype": "Serial and Batch Bundle", @@ -2091,8 +2086,7 @@ def create_serial_batch_no_ledgers( "warehouse": warehouse, "is_rejected": child_row.is_rejected, "type_of_transaction": type_of_transaction, - "posting_date": parent_doc.get("posting_date"), - "posting_time": parent_doc.get("posting_time"), + "posting_datetime": posting_datetime, "company": parent_doc.get("company"), } ) @@ -2128,6 +2122,12 @@ def create_serial_batch_no_ledgers( return doc +def combine_datetime(date, time=None): + from erpnext.stock.utils import get_combine_datetime + + return get_combine_datetime(date, time) + + def get_batch(item_code): from erpnext.stock.doctype.batch.batch import make_batch @@ -2180,8 +2180,8 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh ) doc.voucher_detail_no = child_row.name - doc.posting_date = parent_doc.posting_date - doc.posting_time = parent_doc.posting_time + doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time")) + doc.warehouse = warehouse or doc.warehouse doc.set("entries", []) @@ -2269,6 +2269,9 @@ def get_available_serial_nos(kwargs): elif kwargs.based_on == "Expiry": order_by = "amc_expiry_date" + if not kwargs.get("posting_datetime") and kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + filters = {"item_code": kwargs.item_code} # ignore_warehouse is used for backdated stock transactions @@ -2285,10 +2288,8 @@ def get_available_serial_nos(kwargs): if kwargs.get("ignore_serial_nos"): ignore_serial_nos.extend(kwargs.get("ignore_serial_nos")) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - + ignore_serial_nos = list(set(ignore_serial_nos)) + if kwargs.get("posting_datetime"): time_based_serial_nos = get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos) if not time_based_serial_nos: @@ -2692,6 +2693,9 @@ def get_reserved_batches_for_sre(kwargs) -> dict: def get_auto_batch_nos(kwargs): + if not kwargs.get("posting_datetime") and kwargs.get("posting_date"): + kwargs["posting_datetime"] = combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + available_batches = get_available_batches(kwargs) qty = flt(kwargs.qty) @@ -2721,7 +2725,7 @@ def get_auto_batch_nos(kwargs): if kwargs.based_on == "Expiry": available_batches = sorted(available_batches, key=lambda x: x.expiry_date or getdate("9999-12-31")) - if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_date"): + if not kwargs.get("do_not_check_future_batches") and available_batches and kwargs.get("posting_datetime"): filter_zero_near_batches(available_batches, kwargs) if not kwargs.consider_negative_batches: @@ -2737,8 +2741,7 @@ def get_auto_batch_nos(kwargs): def filter_zero_near_batches(available_batches, kwargs): kwargs.batch_no = [d.batch_no for d in available_batches] - del kwargs["posting_date"] - del kwargs["posting_time"] + del kwargs["posting_datetime"] kwargs.do_not_check_future_batches = 1 available_batches_in_future = get_auto_batch_nos(kwargs) @@ -2804,8 +2807,6 @@ def update_available_batches(available_batches, *reserved_batches) -> None: def get_available_batches(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") batch_ledger = frappe.qb.DocType("Serial and Batch Entry") batch_table = frappe.qb.DocType("Batch") @@ -2833,23 +2834,15 @@ def get_available_batches(kwargs): if not kwargs.get("for_stock_levels"): query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3035,15 +3028,14 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: serial_batch_table.incoming_rate, bundle_table.voucher_detail_no, bundle_table.voucher_no, - bundle_table.posting_date, - bundle_table.posting_time, + bundle_table.posting_datetime, ) .where( (bundle_table.docstatus == 1) & (bundle_table.is_cancelled == 0) & (bundle_table.type_of_transaction.isin(["Inward", "Outward"])) ) - .orderby(bundle_table.posting_date, bundle_table.posting_time) + .orderby(bundle_table.posting_datetime) ) for key, val in kwargs.items(): @@ -3061,7 +3053,7 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: query = query.where(bundle_table[key].isin(val)) else: query = query.where(bundle_table[key] == val) - elif key in ["posting_date", "posting_time"]: + elif key in ["posting_datetime"]: query = query.where(bundle_table[key] >= val) else: if isinstance(val, list): @@ -3073,8 +3065,6 @@ def get_ledgers_from_serial_batch_bundle(**kwargs) -> list[frappe._dict]: def get_stock_ledgers_for_serial_nos(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") query = ( @@ -3090,23 +3080,15 @@ def get_stock_ledgers_for_serial_nos(kwargs): .orderby(stock_ledger_entry.creation) ) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3129,8 +3111,6 @@ def get_stock_ledgers_for_serial_nos(kwargs): def get_stock_ledgers_batches(kwargs): - from erpnext.stock.utils import get_combine_datetime - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") batch_table = frappe.qb.DocType("Batch") @@ -3164,23 +3144,15 @@ def get_stock_ledgers_batches(kwargs): if not kwargs.get("for_stock_levels"): query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())) - if kwargs.get("posting_date"): - if kwargs.get("posting_time") is None: - kwargs.posting_time = nowtime() - - timestamp_condition = stock_ledger_entry.posting_datetime <= get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + if kwargs.get("posting_datetime"): + timestamp_condition = stock_ledger_entry.posting_datetime <= kwargs.posting_datetime if kwargs.get("creation"): - timestamp_condition = stock_ledger_entry.posting_datetime < get_combine_datetime( - kwargs.posting_date, kwargs.posting_time - ) + timestamp_condition = stock_ledger_entry.posting_datetime < kwargs.posting_datetime - timestamp_condition |= ( - stock_ledger_entry.posting_datetime - == get_combine_datetime(kwargs.posting_date, kwargs.posting_time) - ) & (stock_ledger_entry.creation < kwargs.creation) + timestamp_condition |= (stock_ledger_entry.posting_datetime == kwargs.posting_datetime) & ( + stock_ledger_entry.creation < kwargs.creation + ) query = query.where(timestamp_condition) @@ -3268,3 +3240,7 @@ def get_stock_reco_details(voucher_detail_no): ], as_dict=True, ) + + +def on_doctype_update(): + frappe.db.add_index("Serial and Batch Bundle", ["item_code", "warehouse", "posting_datetime", "creation"]) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2c184cbb876..2cee2bb6f0a 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -10,6 +10,7 @@ from frappe.utils import flt, nowtime, today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( add_serial_batch_ledgers, + combine_datetime, make_batch_nos, make_serial_nos, ) @@ -1271,14 +1272,17 @@ def make_serial_batch_bundle(kwargs): if kwargs.get("type_of_transaction"): type_of_transaction = kwargs.get("type_of_transaction") + posting_datetime = None + if kwargs.get("posting_date"): + posting_datetime = combine_datetime(kwargs.posting_date, kwargs.posting_time or nowtime()) + sb = SerialBatchCreation( { "item_code": kwargs.item_code, "warehouse": kwargs.warehouse, "voucher_type": kwargs.voucher_type, "voucher_no": kwargs.voucher_no, - "posting_date": kwargs.posting_date, - "posting_time": kwargs.posting_time, + "posting_datetime": posting_datetime, "qty": kwargs.qty, "avg_rate": kwargs.rate, "batches": kwargs.batches, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index aebcc6dffb2..5a32a7bb641 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -52,7 +52,7 @@ from erpnext.stock.serial_batch_bundle import ( get_serial_or_batch_items, ) from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate -from erpnext.stock.utils import get_bin, get_incoming_rate +from erpnext.stock.utils import get_bin, get_combine_datetime, get_incoming_rate class FinishedGoodError(frappe.ValidationError): @@ -1527,8 +1527,7 @@ class StockEntry(StockController): { "item_code": row.item_code, "warehouse": row.s_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_detail_no": row.name, "qty": row.transfer_qty * -1, @@ -4058,8 +4057,7 @@ def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=N "item_code": child.item_code, "warehouse": child.warehouse, "type_of_transaction": type_of_transaction, - "posting_date": parent_doc.posting_date, - "posting_time": parent_doc.posting_time, + "posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time), } ) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py index e5f4e73e443..1b4c3d7407f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry_utils.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry_utils.py @@ -8,6 +8,7 @@ import frappe from frappe.utils import cint, flt import erpnext +from erpnext.stock.utils import get_combine_datetime if TYPE_CHECKING: from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry @@ -140,6 +141,10 @@ def make_stock_entry(**args): elif args.batches: batches = args.batches + posting_datetime = None + if args.posting_date and args.posting_time: + posting_datetime = get_combine_datetime(args.posting_date, args.posting_time) + bundle_id = ( SerialBatchCreation( { @@ -151,8 +156,7 @@ def make_stock_entry(**args): "serial_nos": args.serial_no, "type_of_transaction": "Outward" if args.source else "Inward", "company": s.company, - "posting_date": s.posting_date, - "posting_time": s.posting_time, + "posting_datetime": posting_datetime, "rate": args.rate or args.basic_rate, "do_not_submit": True, } diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index b3e330a0a85..67f9b57c172 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -13,6 +13,7 @@ from erpnext.controllers.stock_controller import StockController from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + combine_datetime, get_available_serial_nos, ) from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -136,8 +137,7 @@ class StockReconciliation(StockController): { "item_code": row.item_code, "warehouse": row.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, @@ -243,8 +243,7 @@ class StockReconciliation(StockController): "doctype": "Serial and Batch Bundle", "item_code": item.item_code, "warehouse": item.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "voucher_type": self.doctype, "type_of_transaction": "Outward", } @@ -262,8 +261,7 @@ class StockReconciliation(StockController): { "item_code": item.item_code, "warehouse": item.warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, + "posting_datetime": combine_datetime(self.posting_date, self.posting_time), "ignore_warehouse": 1, } ) diff --git a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py index 486828af1cc..f821899e209 100644 --- a/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py +++ b/erpnext/stock/report/serial_and_batch_summary/serial_and_batch_summary.py @@ -19,7 +19,7 @@ def get_data(filters): "Serial and Batch Bundle", fields=[ "`tabSerial and Batch Bundle`.`voucher_type`", - "`tabSerial and Batch Bundle`.`posting_date`", + "`tabSerial and Batch Bundle`.`posting_datetime` as posting_date", "`tabSerial and Batch Bundle`.`name`", "`tabSerial and Batch Bundle`.`company`", "`tabSerial and Batch Bundle`.`voucher_no`", @@ -33,7 +33,7 @@ def get_data(filters): "`tabSerial and Batch Entry`.`qty`", ], filters=filter_conditions, - order_by="posting_date", + order_by="posting_datetime", ) @@ -54,7 +54,7 @@ def get_filter_conditions(filters): filter_conditions.append( [ "Serial and Batch Bundle", - "posting_date", + "posting_datetime", "between", [filters.get("from_date"), filters.get("to_date")], ] diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index b2eba994538..1b030dbe2fa 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -5,7 +5,7 @@ from frappe import _, bold from frappe.model.naming import NamingSeries, make_autoname, parse_naming_series from frappe.query_builder import Case from frappe.query_builder.functions import CombineDatetime, Max, Sum, Timestamp -from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, now, nowtime, today +from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, now, nowtime, today from pypika import Order from pypika.terms import ExistsCriterion @@ -140,8 +140,7 @@ class SerialBatchBundle: { "item_code": self.item_code, "warehouse": self.warehouse, - "posting_date": self.sle.posting_date, - "posting_time": self.sle.posting_time, + "posting_datetime": self.sle.posting_datetime, "voucher_type": self.sle.voucher_type, "voucher_no": self.sle.voucher_no, "voucher_detail_no": self.sle.voucher_detail_no, @@ -484,7 +483,7 @@ class SerialBatchBundle: if status == "Delivered": warranty_period = frappe.get_cached_value("Item", sle.item_code, "warranty_period") if warranty_period: - warranty_expiry_date = add_days(sle.posting_date, cint(warranty_period)) + warranty_expiry_date = add_days(getdate(sle.posting_datetime), cint(warranty_period)) query = query.set(sn_table.warranty_expiry_date, warranty_expiry_date) query = query.set(sn_table.warranty_period, warranty_period) else: @@ -509,7 +508,7 @@ class SerialBatchBundle: sle_doctype.voucher_no, sle_doctype.is_cancelled, sle_doctype.item_code, - sle_doctype.posting_date, + sle_doctype.posting_datetime, sle_doctype.company, ) .where( @@ -663,7 +662,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation): .on(bundle.name == bundle_child.parent) .select( bundle_child.serial_no, - Max(CombineDatetime(bundle.posting_date, bundle.posting_time)).as_("max_posting_dt"), + Max(bundle.posting_datetime).as_("max_posting_dt"), ) .where( (bundle.is_cancelled == 0) @@ -681,13 +680,8 @@ class SerialNoValuation(DeprecatedSerialNoValuation): if self.sle.voucher_no: latest_posting = latest_posting.where(bundle.voucher_no != self.sle.voucher_no) - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - timestamp_condition = CombineDatetime( - bundle.posting_date, bundle.posting_time - ) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time) + if self.sle.posting_datetime: + timestamp_condition = bundle.posting_datetime <= self.sle.posting_datetime latest_posting = latest_posting.where(timestamp_condition) @@ -704,10 +698,7 @@ class SerialNoValuation(DeprecatedSerialNoValuation): .join(latest_posting) .on( (latest_posting.serial_no == bundle_child.serial_no) - & ( - latest_posting.max_posting_dt - == CombineDatetime(bundle.posting_date, bundle.posting_time) - ) + & (latest_posting.max_posting_dt == bundle.posting_datetime) ) .select( bundle_child.serial_no, @@ -839,19 +830,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation): child = frappe.qb.DocType("Serial and Batch Entry") timestamp_condition = "" - if self.sle.posting_date: - if self.sle.posting_time is None: - self.sle.posting_time = nowtime() - - timestamp_condition = CombineDatetime(parent.posting_date, parent.posting_time) < CombineDatetime( - self.sle.posting_date, self.sle.posting_time - ) + if self.sle.posting_datetime: + timestamp_condition = parent.posting_datetime < self.sle.posting_datetime if self.sle.creation: - timestamp_condition |= ( - CombineDatetime(parent.posting_date, parent.posting_time) - == CombineDatetime(self.sle.posting_date, self.sle.posting_time) - ) & (parent.creation < self.sle.creation) + timestamp_condition |= (parent.posting_datetime == self.sle.posting_datetime) & ( + parent.creation < self.sle.creation + ) query = ( frappe.qb.from_(parent) @@ -1074,9 +1059,9 @@ class SerialBatchCreation: self.__dict__.update(item_details) def set_other_details(self): - if not self.get("posting_date"): - self.posting_date = today() - self.__dict__["posting_date"] = self.posting_date + if not self.get("posting_datetime"): + self.posting_datetime = now() + self.__dict__["posting_datetime"] = self.posting_datetime if not self.get("actual_qty"): qty = self.get("qty") or self.get("total_qty") @@ -1101,8 +1086,7 @@ class SerialBatchCreation: new_package.docstatus = 0 new_package.warehouse = self.warehouse new_package.voucher_no = "" - new_package.posting_date = self.posting_date if hasattr(self, "posting_date") else today() - new_package.posting_time = self.posting_time if hasattr(self, "posting_time") else nowtime() + new_package.posting_datetime = self.posting_datetime if hasattr(self, "posting_datetime") else now() new_package.type_of_transaction = self.type_of_transaction new_package.returned_against = self.get("returned_against") @@ -1242,9 +1226,8 @@ class SerialBatchCreation: elif self.has_serial_no and not self.get("serial_nos"): self.serial_nos = get_serial_nos_for_outward(kwargs) elif not self.has_serial_no and self.has_batch_no and not self.get("batches"): - if self.get("posting_date"): - kwargs["posting_date"] = self.get("posting_date") - kwargs["posting_time"] = self.get("posting_time") + if self.get("posting_datetime"): + kwargs["posting_datetime"] = self.get("posting_datetime") self.batches = get_available_batches(kwargs) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7ae0a2fda49..bcc5b3e8a26 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1975,9 +1975,6 @@ def get_valuation_rate( # Get moving average rate of a specific batch number if warehouse and serial_and_batch_bundle: - sabb = frappe.db.get_value( - "Serial and Batch Bundle", serial_and_batch_bundle, ["posting_date", "posting_time"], as_dict=True - ) batch_obj = BatchNoValuation( sle=frappe._dict( { @@ -1985,8 +1982,9 @@ def get_valuation_rate( "warehouse": warehouse, "actual_qty": -1, "serial_and_batch_bundle": serial_and_batch_bundle, - "posting_date": sabb.posting_date, - "posting_time": sabb.posting_time, + "posting_datetime": frappe.get_value( + "Serial and Batch Bundle", serial_and_batch_bundle, "posting_datetime" + ), } ) ) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 8dfcf5a833e..92a42ec714c 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -2,12 +2,14 @@ # License: GNU General Public License v3. See license.txt +import datetime import json import frappe from frappe import _ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime +from frappe.utils.data import DateTimeLikeObject import erpnext from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions @@ -94,13 +96,13 @@ def get_stock_value_on( @frappe.whitelist() def get_stock_balance( - item_code, - warehouse, - posting_date=None, - posting_time=None, - with_valuation_rate=False, - with_serial_no=False, - inventory_dimensions_dict=None, + item_code: str, + warehouse: str | None, + posting_date: DateTimeLikeObject | None = None, + posting_time: DateTimeLikeObject | datetime.timedelta | None = None, + with_valuation_rate: bool = False, + with_serial_no: bool = False, + inventory_dimensions_dict: dict | None = None, ): """Returns stock balance quantity at given warehouse on given posting date or current date. @@ -146,8 +148,7 @@ def get_stock_balance( { "item_code": item_code, "warehouse": warehouse, - "posting_date": posting_date, - "posting_time": posting_time, + "posting_datetime": get_combine_datetime(posting_date, posting_time), "ignore_warehouse": 1, } ) @@ -247,13 +248,16 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() -def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True): +def get_incoming_rate(args: dict | str, raise_error_if_no_rate: bool = True, fallbacks: bool = True): """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate if isinstance(args, str): args = json.loads(args) + if not args.get("posting_datetime") and args.get("posting_date"): + args["posting_datetime"] = get_combine_datetime(args.get("posting_date"), args.get("posting_time")) + in_rate = None item_details = frappe.get_cached_value( From 5c4f19ebdc10ca97337babf3c366d44d44931d33 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 18 Jun 2026 13:35:33 +0530 Subject: [PATCH 094/132] feat: add batch-level option to allow negative stock for batch --- erpnext/stock/doctype/batch/batch.json | 11 ++++++++++- erpnext/stock/doctype/batch/batch.py | 1 + .../serial_and_batch_bundle.py | 17 +++++++++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index e318f113713..00541250531 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -11,6 +11,7 @@ "disabled", "column_break_24", "use_batchwise_valuation", + "allow_negative_stock_for_batch", "sb_batch", "batch_id", "item", @@ -202,6 +203,14 @@ "label": "Use Batch-wise Valuation", "read_only": 1, "set_only_once": 1 + }, + { + "default": "0", + "description": "If enabled, the system will allow negative stock entries for this batch, overriding the 'Allow negative stock for Batch' setting in Stock Settings. This may lead to incorrect valuation rates, so it is recommended to avoid using this option.", + "fieldname": "allow_negative_stock_for_batch", + "fieldtype": "Check", + "label": "Allow Negative Stock for Batch", + "no_copy": 1 } ], "icon": "fa fa-archive", @@ -209,7 +218,7 @@ "image_field": "image", "links": [], "max_attachments": 5, - "modified": "2026-06-16 16:01:26.556324", + "modified": "2026-06-17 12:17:28.339975", "modified_by": "Administrator", "module": "Stock", "name": "Batch", diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 7f31c65dfcf..b3a85b0bdd6 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -95,6 +95,7 @@ class Batch(Document): if TYPE_CHECKING: from frappe.types import DF + allow_negative_stock_for_batch: DF.Check batch_id: DF.Data batch_qty: DF.Float description: DF.SmallText | None diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index ce185e40a31..f8facea5f78 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1508,7 +1508,7 @@ class SerialandBatchBundle(Document): def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None): from erpnext.stock.stock_ledger import NegativeStockError - if frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch"): + if allow_negative_stock_for_batch(batch_no): return date_msg = "" @@ -1519,7 +1519,7 @@ class SerialandBatchBundle(Document): """ The Batch {0} of an item {1} has negative stock in the warehouse {2}{3}. Please add a stock quantity of {4} to proceed with this entry. - If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in Stock Settings to proceed. + If it is not possible to make an adjustment entry, please enable 'Allow Negative Stock for Batch' in the batch {0} or Stock Settings to proceed. However, enabling this setting may lead to negative stock in the system. So please ensure the stock levels are adjusted as soon as possible to maintain the correct valuation rate.""" ).format( @@ -2128,6 +2128,19 @@ def combine_datetime(date, time=None): return get_combine_datetime(date, time) +def allow_negative_stock_for_batch(batch_no): + """Return whether negative stock is allowed for the given batch. + + The batch-level setting takes priority: if `allow_negative_stock_for_batch` + is enabled on the Batch, negative stock is allowed regardless of Stock Settings. + Otherwise, fall back to the `allow_negative_stock_for_batch` Stock Setting. + """ + if batch_no and frappe.db.get_value("Batch", batch_no, "allow_negative_stock_for_batch"): + return True + + return bool(frappe.db.get_single_value("Stock Settings", "allow_negative_stock_for_batch")) + + def get_batch(item_code): from erpnext.stock.doctype.batch.batch import make_batch From ce8fce78f15694bb4ccb668138471d26ff8eb555 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:32:54 +0000 Subject: [PATCH 095/132] fix: Add likely missing escapes (backport #55574) (#55580) * fix: Add likely missing escaps (#55574) (cherry picked from commit b72cde73ba355a0451a0236df1303b3c8602cd28) # Conflicts: # erpnext/accounts/doctype/budget/budget.py # erpnext/controllers/website_list_for_contact.py * chore: conflicts --------- Co-authored-by: Ankush Menat --- erpnext/accounts/doctype/budget/budget.py | 7 +++---- .../doctype/journal_entry/journal_entry.py | 6 +++++- .../bulk_transaction_log.py | 3 ++- erpnext/controllers/status_updater.py | 17 ++++++++------- .../controllers/website_list_for_contact.py | 2 +- .../inactive_customers/inactive_customers.py | 3 +++ .../report/sales_analytics/sales_analytics.py | 21 ++++++++++++------- .../authorization_control.py | 8 ++++--- .../material_request/material_request.py | 2 +- erpnext/stock/stock_balance.py | 2 +- 10 files changed, 44 insertions(+), 27 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 32225fff93d..4ef1f164aaa 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -425,11 +425,11 @@ def get_ordered_amount(args): def get_other_condition(args, for_doc): - condition = "expense_account = '%s'" % (args.expense_account) + condition = f"expense_account = {frappe.db.escape(args.expense_account)}" budget_against_field = args.get("budget_against_field") if budget_against_field and args.get(budget_against_field): - condition += f" and child.{budget_against_field} = '{args.get(budget_against_field)}'" + condition += f" and child.{budget_against_field} = {frappe.db.escape(args.get(budget_against_field))}" if args.get("fiscal_year"): date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date" @@ -437,8 +437,7 @@ def get_other_condition(args, for_doc): "Fiscal Year", args.get("fiscal_year"), ["year_start_date", "year_end_date"] ) - condition += f""" and parent.{date_field} - between '{start_date}' and '{end_date}' """ + condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}" return condition diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index 502a4f9e015..aa048a71ff2 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -1184,7 +1184,11 @@ class JournalEntry(AccountsController): self.validate_total_debit_and_credit() def get_values(self): - cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else "" + cond = ( + f" and outstanding_amount <= {flt(self.write_off_amount)}" + if flt(self.write_off_amount) > 0 + else "" + ) if self.write_off_based_on == "Accounts Receivable": return frappe.db.sql( diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index 2733d07a476..fbe9d7fcf7d 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -31,7 +31,8 @@ class BulkTransactionLog(Document): log_detail = qb.DocType("Bulk Transaction Log Detail") has_records = frappe.db.sql( - f"select exists (select * from `tabBulk Transaction Log Detail` where date = '{self.name}');" + "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", + (self.name,), )[0][0] if not has_records: raise frappe.DoesNotExistError diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index c3d3627dfe6..21e6d3ea8b9 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -394,9 +394,9 @@ class StatusUpdater(Document): for args in self.status_updater: # condition to include current record (if submit or no if cancel) if self.docstatus == 1: - args["cond"] = " or parent='%s'" % self.name.replace('"', '"') + args["cond"] = " or parent=%s" % frappe.db.escape(self.name) else: - args["cond"] = " and parent!='%s'" % self.name.replace('"', '"') + args["cond"] = " and parent!=%s" % frappe.db.escape(self.name) self._update_children(args, update_modified) @@ -426,9 +426,10 @@ class StatusUpdater(Document): args["second_source_condition"] = frappe.db.sql( """ select ifnull((select sum({second_source_field}) from `tab{second_source_dt}` - where `{second_join_field}`='{detail_id}' + where `{second_join_field}`=%(detail_id)s and (`tab{second_source_dt}`.docstatus=1) - {second_source_extra_cond}), 0) """.format(**args) + {second_source_extra_cond}), 0) """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] if args["detail_id"]: @@ -439,9 +440,10 @@ class StatusUpdater(Document): frappe.db.sql( """ (select ifnull(sum({source_field}), 0) - from `tab{source_dt}` where `{join_field}`='{detail_id}' + from `tab{source_dt}` where `{join_field}`=%(detail_id)s and (docstatus=1 {cond}) {extra_cond}) - """.format(**args) + """.format(**args), + {"detail_id": args["detail_id"]}, )[0][0] or 0.0 ) @@ -452,7 +454,8 @@ class StatusUpdater(Document): frappe.db.sql( """update `tab{target_dt}` set {target_field} = {source_dt_value} {update_modified} - where name='{detail_id}'""".format(**args) + where name=%(detail_id)s""".format(**args), + {"detail_id": args["detail_id"]}, ) def _update_percent_field_in_targets(self, args, update_modified=True): diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index f3f1f1b0651..ea7b47bd487 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -7,7 +7,7 @@ import json import frappe from frappe import _ from frappe.modules.utils import get_module_app -from frappe.utils import flt, has_common +from frappe.utils import cint, flt, has_common from frappe.utils.user import is_website_user diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 7e4ddc128ac..d21d11b2447 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -14,6 +14,9 @@ def execute(filters=None): days_since_last_order = filters.get("days_since_last_order") doctype = filters.get("doctype") + if doctype not in ("Sales Order", "Sales Invoice"): + frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype)) + if cint(days_since_last_order) <= 0: frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero")) diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 5786adc6881..5d4478f8dd6 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -427,14 +427,16 @@ class Analytics: break def get_groups(self): - if self.filters.tree_type == "Territory": - parent = "parent_territory" - if self.filters.tree_type == "Customer Group": - parent = "parent_customer_group" - if self.filters.tree_type == "Item Group": - parent = "parent_item_group" - if self.filters.tree_type == "Supplier Group": - parent = "parent_supplier_group" + parent_field_map = { + "Territory": "parent_territory", + "Customer Group": "parent_customer_group", + "Item Group": "parent_item_group", + "Supplier Group": "parent_supplier_group", + } + if self.filters.tree_type not in parent_field_map: + frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type)) + + parent = parent_field_map[self.filters.tree_type] self.depth_map = frappe._dict() @@ -453,6 +455,9 @@ class Analytics: def get_teams(self): self.depth_map = frappe._dict() + if not frappe.db.exists("DocType", self.filters.doc_type): + frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type)) + self.group_entries = frappe.db.sql( f""" select * from (select "Order Types" as name, 0 as lft, 2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index 4298ffc9ec6..cfa2eef8915 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase): if val == 1: add_cond += " and system_user = {}".format(frappe.db.escape(session["user"])) elif val == 2: - add_cond += " and system_role IN %s" % ("('" + "','".join(frappe.get_roles()) + "')") + add_cond += " and system_role IN (%s)" % ", ".join( + frappe.db.escape(r) for r in frappe.get_roles() + ) else: add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''" @@ -203,8 +205,8 @@ class AuthorizationControl(TransactionBase): and docstatus != 2 """.format( "%s", - "'" + "','".join(frappe.get_roles()) + "'", - "'" + "','".join(final_based_on) + "'", + ", ".join(frappe.db.escape(r) for r in frappe.get_roles()), + ", ".join(frappe.db.escape(b) for b in final_based_on), "%s", ), (doctype_name, company), diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 068daeae4f1..ffd37d91df4 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -209,7 +209,7 @@ class MaterialRequest(BuyingController): def check_modified_date(self): mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name) - date_diff = frappe.db.sql(f"""select TIMEDIFF('{mod_db[0][0]}', '{cstr(self.modified)}')""") + date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified))) if date_diff and date_diff[0][0]: frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name)) diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index c3f5086fbc5..d11a2f55398 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -283,7 +283,7 @@ def set_stock_balance_as_per_serial_no( if not posting_time: posting_time = nowtime() - condition = " and item.name='%s'" % item_code.replace("'", "'") if item_code else "" + condition = " and item.name=%s" % frappe.db.escape(item_code, percent=False) if item_code else "" bin = frappe.db.sql( """select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom From e4370ab332cea148446b3a5aca9597c559e2e583 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 17 Jun 2026 18:01:14 +0530 Subject: [PATCH 096/132] fix: disable is_debit_note while creating credit note (cherry picked from commit 279c8dea0630215553bacdd2792f97a7725ac36b) --- erpnext/controllers/sales_and_purchase_return.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 8f6edbe75ae..e1e3ba3e84e 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -380,6 +380,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai doc.pricing_rules = [] doc.return_against = source.name doc.set_warehouse = "" + if doctype == "Sales Invoice": + doc.is_debit_note = 0 if doctype == "Sales Invoice" or doctype == "POS Invoice": doc.is_pos = source.is_pos From 90fd057fb3a9ec577d65677928a7a7bdbea790de Mon Sep 17 00:00:00 2001 From: pandiyan Date: Thu, 18 Jun 2026 18:05:46 +0530 Subject: [PATCH 097/132] fix: apply docstatus filter to exclude cancelled Work Orders in Serial No (cherry picked from commit 3ba8f690a462107252bbae3c0b6cb5a1568761a3) --- erpnext/stock/doctype/serial_no/serial_no.js | 40 +++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/erpnext/stock/doctype/serial_no/serial_no.js b/erpnext/stock/doctype/serial_no/serial_no.js index b12bbbdb27c..6e8363287a0 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.js +++ b/erpnext/stock/doctype/serial_no/serial_no.js @@ -1,26 +1,30 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.add_fetch("customer", "customer_name", "customer_name"); -cur_frm.add_fetch("supplier", "supplier_name", "supplier_name"); - -cur_frm.add_fetch("item_code", "item_name", "item_name"); -cur_frm.add_fetch("item_code", "description", "description"); -cur_frm.add_fetch("item_code", "item_group", "item_group"); -cur_frm.add_fetch("item_code", "brand", "brand"); - -cur_frm.cscript.onload = function () { - cur_frm.set_query("item_code", function () { - return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 }); - }); -}; - -frappe.ui.form.on("Serial No", "refresh", function (frm) { - frm.toggle_enable("item_code", frm.doc.__islocal); -}); - frappe.ui.form.on("Serial No", { + setup(frm) { + frm.add_fetch("customer", "customer_name", "customer_name"); + frm.add_fetch("supplier", "supplier_name", "supplier_name"); + frm.add_fetch("item_code", "item_name", "item_name"); + frm.add_fetch("item_code", "description", "description"); + frm.add_fetch("item_code", "item_group", "item_group"); + frm.add_fetch("item_code", "brand", "brand"); + + frm.set_query("item_code", function () { + return erpnext.queries.item({ is_stock_item: 1, has_serial_no: 1 }); + }); + + frm.set_query("work_order", () => { + return { + filters: { + docstatus: 1, + }, + }; + }); + }, + refresh(frm) { + frm.toggle_enable("item_code", frm.doc.__islocal); frm.trigger("view_ledgers"); }, From 37dffa72738133dcbccbb8e3ec8cfcf5c1a9ed97 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Thu, 18 Jun 2026 20:15:03 +0530 Subject: [PATCH 098/132] fix: tax.base_tax_amount as none when payment entry created using API (cherry picked from commit b9b402f2eca1fca77451fa860bc55b7a65cd8496) --- .../doctype/payment_entry/payment_entry.py | 4 ++-- .../payment_entry/test_payment_entry.py | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 26c980a1946..148dd4edcc5 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -1197,9 +1197,9 @@ class PaymentEntry(AccountsController): continue if tax.add_deduct_tax == "Add": - included_taxes += tax.base_tax_amount + included_taxes += flt(tax.base_tax_amount) else: - included_taxes -= tax.base_tax_amount + included_taxes -= flt(tax.base_tax_amount) return included_taxes diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 79bd66f735a..c52193cc469 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -1118,6 +1118,27 @@ class TestPaymentEntry(FrappeTestCase): self.assertEqual(gl_entries, expected_gl_entries) + def test_payment_entry_with_inclusive_tax(self): + # inclusive tax built server-side: base_tax_amount is None until apply_taxes() + payment_entry = create_payment_entry(paid_amount=1180) + payment_entry.append( + "taxes", + { + "account_head": "_Test Account Service Tax - _TC", + "charge_type": "On Paid Amount", + "rate": 18, + "included_in_paid_amount": 1, + "add_deduct_tax": "Add", + "description": "Service Tax", + }, + ) + payment_entry.save() + payment_entry.submit() + + # 1180 incl 18% => 1000 base + 180 tax + self.assertEqual(flt(payment_entry.total_taxes_and_charges, 2), 180.0) + self.assertEqual(flt(payment_entry.unallocated_amount, 2), 1000.0) + def test_payment_entry_against_onhold_purchase_invoice(self): pi = make_purchase_invoice() From e556cbbe6a36c094ed33e1cf484c21bc792b3c6c Mon Sep 17 00:00:00 2001 From: nareshkannasln Date: Tue, 26 May 2026 12:39:51 +0530 Subject: [PATCH 099/132] fix: update reference doctype mapping and field visibility in bank guarantee (cherry picked from commit b1de654dfd71cfa5c86e5f57d13da8d4f6d90007) # Conflicts: # erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json --- .../doctype/bank_guarantee/bank_guarantee.js | 7 +++++++ .../doctype/bank_guarantee/bank_guarantee.json | 12 ++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js index c34e0f9099c..8d36c731d37 100644 --- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js +++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.js @@ -9,6 +9,13 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number"); frappe.ui.form.on("Bank Guarantee", { setup: function (frm) { + frm.set_query("reference_doctype", function () { + return { + filters: { + name: ["in", ["Sales Order", "Purchase Order"]], + }, + }; + }); frm.set_query("bank_account", function () { return { filters: { diff --git a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json index c255ce7ba8f..42e92613ea8 100644 --- a/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json +++ b/erpnext/accounts/doctype/bank_guarantee/bank_guarantee.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "ACC-BG-.YYYY.-.#####", "creation": "2016-12-17 10:43:35.731631", "doctype": "DocType", @@ -50,8 +51,7 @@ "fieldname": "reference_doctype", "fieldtype": "Link", "label": "Reference Document Type", - "options": "DocType", - "read_only": 1 + "options": "DocType" }, { "fieldname": "reference_docname", @@ -60,14 +60,14 @@ "options": "reference_doctype" }, { - "depends_on": "eval: doc.bg_type == \"Receiving\"", + "depends_on": "eval: doc.reference_doctype == \"Sales Order\"", "fieldname": "customer", "fieldtype": "Link", "label": "Customer", "options": "Customer" }, { - "depends_on": "eval: doc.bg_type == \"Providing\"", + "depends_on": "eval: doc.reference_doctype == \"Purchase Order\"", "fieldname": "supplier", "fieldtype": "Link", "label": "Supplier", @@ -217,11 +217,11 @@ ], "is_submittable": 1, "links": [], - "modified": "2025-09-26 00:38:17.584694", + "modified": "2026-05-25 18:12:10.768835", "modified_by": "Administrator", "module": "Accounts", "name": "Bank Guarantee", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { From 041a9adbbf7f211ad75d84e9c79c7b94b319815e Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 20 Jun 2026 19:25:58 +0530 Subject: [PATCH 100/132] fix(err): add missing permission check on `get_account_details` (cherry picked from commit 9bf1e847d2cf97d2700e5a1e9b1c339c083fd712) --- .../exchange_rate_revaluation/exchange_rate_revaluation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py index 96272c0d901..41249662624 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.py @@ -619,6 +619,10 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party): def get_account_details( company, posting_date, account, party_type=None, party=None, rounding_loss_allowance: float | None = None ): + if not account: + return + frappe.has_permission("Account", doc=account, throw=True) + if not (company and posting_date): frappe.throw(_("Company and Posting Date is mandatory")) From 0efebf5d8ce73524057d0634683b3e618922600b Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 20 Jun 2026 19:28:20 +0530 Subject: [PATCH 101/132] fix(report_utils): remove unnecessary whitelist decorator on `get_invoiced_item_gross_margin` (cherry picked from commit e29535f29c50db187138585d11d5fabf3428598c) # Conflicts: # erpnext/accounts/report/utils.py --- erpnext/accounts/report/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index bf604a36db0..64943db96ba 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -146,8 +146,17 @@ def get_appropriate_company(filters): return company +<<<<<<< HEAD @frappe.whitelist() def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False): +======= +def get_invoiced_item_gross_margin( + sales_invoice: str | None = None, + item_code: str | None = None, + company: str | None = None, + with_item_data: bool = False, +): +>>>>>>> e29535f29c (fix(report_utils): remove unnecessary whitelist decorator on `get_invoiced_item_gross_margin`) from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator sales_invoice = sales_invoice or frappe.form_dict.get("sales_invoice") From 7a04f031d9db0e3dda69060dc041323c95f728f4 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 20 Jun 2026 19:58:49 +0530 Subject: [PATCH 102/132] chore: resolve conflicts --- erpnext/accounts/report/utils.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 64943db96ba..8d1730ab294 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -146,17 +146,7 @@ def get_appropriate_company(filters): return company -<<<<<<< HEAD -@frappe.whitelist() def get_invoiced_item_gross_margin(sales_invoice=None, item_code=None, company=None, with_item_data=False): -======= -def get_invoiced_item_gross_margin( - sales_invoice: str | None = None, - item_code: str | None = None, - company: str | None = None, - with_item_data: bool = False, -): ->>>>>>> e29535f29c (fix(report_utils): remove unnecessary whitelist decorator on `get_invoiced_item_gross_margin`) from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator sales_invoice = sales_invoice or frappe.form_dict.get("sales_invoice") From acc1444c030ab2bf38baefb101c9fdd9e7a1ed1b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 20 Jun 2026 23:50:50 +0530 Subject: [PATCH 103/132] fix(stock): apply precision to the additional cost amount in stock entry (cherry picked from commit 6ac699d3bb44cfb9d96536a5271e2e5654d221c3) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 3ed696e0a02..69d8e96f6f2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2005,6 +2005,8 @@ class StockEntry(StockController): ] += flt(t.base_amount * multiply_based_on) / divide_based_on if item_account_wise_additional_cost: + precision = self.get_debit_field_precision() + for d in self.get("items"): for account, amount in item_account_wise_additional_cost.get( (d.item_code, d.name), {} @@ -2012,6 +2014,9 @@ class StockEntry(StockController): if not amount: continue + amount["amount"] = flt(amount["amount"], precision) + amount["base_amount"] = flt(amount["base_amount"], precision) + gl_entries.append( self.get_gl_dict( { From deb11745e5795a91bf9228788c4c489ad3a2597a Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sat, 20 Jun 2026 23:51:16 +0530 Subject: [PATCH 104/132] test(stock): add test to validate the precision for additional cost amount (cherry picked from commit 20b14395e30e6b97520cd418f8dbdd4644c84600) --- .../doctype/stock_entry/test_stock_entry.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index d0d60276a97..ea231ff466c 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -547,6 +547,60 @@ class TestStockEntry(FrappeTestCase): ), ) + def test_additional_cost_no_rounding_residual_on_stock_adjustment(self): + company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") + warehouse = "Stores - TCP1" + items = [ + make_item(f"_Test Addl Cost Rounding {x}", {"is_stock_item": 1}).name for x in ("A", "B", "C") + ] + + for item_code in items: + make_stock_entry(item_code=item_code, target=warehouse, company=company, qty=100, basic_rate=10) + + transfer = make_stock_entry(company=company, purpose="Material Transfer", do_not_save=True) + transfer.from_warehouse = warehouse + transfer.to_warehouse = warehouse + transfer.items = [] + for item_code in items: + transfer.append( + "items", + { + "item_code": item_code, + "qty": 100, + "s_warehouse": warehouse, + "t_warehouse": warehouse, + "uom": "Nos", + "conversion_factor": 1, + }, + ) + transfer.append( + "additional_costs", + { + "expense_account": "Expenses Included In Valuation - TCP1", + "description": "freight", + "amount": 100, + }, + ) + transfer.insert() + transfer.submit() + + gl_entries = frappe.get_all( + "GL Entry", + filters={"voucher_type": "Stock Entry", "voucher_no": transfer.name}, + fields=["account", "debit", "credit"], + ) + gl_map = {} + for row in gl_entries: + account = gl_map.setdefault(row.account, frappe._dict(debit=0.0, credit=0.0)) + account.debit += row.debit + account.credit += row.credit + + self.assertNotIn("Stock Adjustment - TCP1", gl_map) + + stock_in_hand_account = get_inventory_account(company, warehouse) + self.assertEqual(flt(gl_map[stock_in_hand_account].debit, 2), 99.99) + self.assertEqual(flt(gl_map["Expenses Included In Valuation - TCP1"].credit, 2), 99.99) + def check_stock_ledger_entries(self, voucher_type, voucher_no, expected_sle): expected_sle.sort(key=lambda x: x[1]) From 37f847e730aafae2fc026c008bd391c15feace1a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:07:38 +0530 Subject: [PATCH 105/132] fix(stock): update voucher valuaion rate in sle (backport #55960) (#56262) fix(stock): update voucher valuaion rate in sle (#55960) (cherry picked from commit 130c2594e1e999708c5b70708ba108201ab83931) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- erpnext/stock/stock_ledger.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index bcc5b3e8a26..9ee458ce9c7 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -908,6 +908,16 @@ class update_entries_after: and not has_dimensions ): # assert + if ( + sle.voucher_detail_no + and self.repost_doc + and self.repost_doc.get("recalculate_valuation_rate") + ): + source_rate = frappe.get_cached_value( + "Stock Reconciliation Item", sle.voucher_detail_no, "valuation_rate" + ) + if source_rate: + sle.valuation_rate = source_rate self.wh_data.valuation_rate = sle.valuation_rate self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( From a858d7746141cd511de8519d3c324d9f3f111817 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 21 Jun 2026 00:52:21 +0530 Subject: [PATCH 106/132] fix(stock): allow partial raw material picking/transfer from work order (cherry picked from commit 8e3fbab94ae6fbc19b378777a149b49e4f40175b) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.py --- .../doctype/work_order/work_order.js | 25 +++++--- .../doctype/work_order/work_order.py | 30 ++++++++++ erpnext/stock/doctype/pick_list/pick_list.py | 2 +- .../stock/doctype/stock_entry/stock_entry.py | 59 ++++++++++++++++++- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 3b3448333d9..5905d40b2f3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -979,17 +979,24 @@ erpnext.work_order = { }, create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") { - this.show_prompt_for_qty_input(frm, purpose) - .then((data) => { - return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", { + const max = this.get_max_transferable_qty(frm, purpose); + + const get_pick_list = (for_qty) => + frappe + .xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", { source_name: frm.doc.name, - for_qty: data.qty, + for_qty: for_qty, + }) + .then((pick_list) => { + frappe.model.sync(pick_list); + frappe.set_route("Form", pick_list.doctype, pick_list.name); }); - }) - .then((pick_list) => { - frappe.model.sync(pick_list); - frappe.set_route("Form", pick_list.doctype, pick_list.name); - }); + + if (max <= 0) { + get_pick_list(frm.doc.qty); + } else { + this.show_prompt_for_qty_input(frm, purpose).then((data) => get_pick_list(data.qty)); + } }, make_consumption_se: function (frm, backflush_raw_materials_based_on) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 9d3100302e7..d6764005a80 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1241,6 +1241,36 @@ class WorkOrder(Document): "transferred_qty", (transferred_items.get(row.item_code) or 0.0), update_modified=False ) + self.recompute_material_transferred_for_manufacturing(transferred_items) + + def recompute_material_transferred_for_manufacturing(self, transferred_items): + """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty.""" + # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the + # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly. + sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture") + if sum_fg_completed_qty: + self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty) + return + + # Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers + # so partial availability does not prematurely mark the work order as fully transferred. + required_by_item = {} + for row in self.required_items: + if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0: + continue + required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty) + + if not required_by_item: + return + + min_fraction = min( + flt(transferred_items.get(item_code) or 0) / required_qty + for item_code, required_qty in required_by_item.items() + ) + min_fraction = min(min_fraction, 1.0) + material_transferred = min_fraction * flt(self.qty) + self.db_set("material_transferred_for_manufacturing", material_transferred) + def update_returned_qty(self): ste = frappe.qb.DocType("Stock Entry") ste_child = frappe.qb.DocType("Stock Entry Detail") diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 41624477882..6d42f51a8d6 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1577,7 +1577,7 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry): stock_entry.from_bom = 1 stock_entry.bom_no = work_order.bom_no stock_entry.use_multi_level_bom = work_order.use_multi_level_bom - stock_entry.fg_completed_qty = pick_list.for_qty + stock_entry.fg_completed_qty = 0 if work_order.bom_no: stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required") diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 69d8e96f6f2..3aea5271d3d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1146,10 +1146,12 @@ class StockEntry(StockController): if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]: return - if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): + if not self.fg_completed_qty: + if self.work_order and self.purpose == "Material Transfer for Manufacture": + self._validate_no_excess_transfer() return - if not self.fg_completed_qty: + if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): return raw_materials = self.get_bom_raw_materials(self.fg_completed_qty) @@ -1174,6 +1176,59 @@ class StockEntry(StockController): title=_("Missing Item"), ) + def _validate_no_excess_transfer(self): + if self.is_return: + return + + if ( + frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") + == "Material Transferred for Manufacture" + ): + return + + wo = self.pro_doc + if not wo: + return + + pending_by_item = {} + for r in wo.required_items: + pending_by_item[r.item_code] = ( + pending_by_item.get(r.item_code, 0.0) + flt(r.required_qty) - flt(r.transferred_qty) + ) + + transfer_by_item = {} + first_row_by_item = {} + for item in self.items: + if not item.s_warehouse: + continue + + key = ( + item.item_code if item.item_code in pending_by_item else getattr(item, "original_item", None) + ) + if key not in pending_by_item: + continue + + transfer_by_item[key] = transfer_by_item.get(key, 0.0) + flt(item.qty) + first_row_by_item.setdefault(key, item) + + for key, transfer_qty in transfer_by_item.items(): + pending_qty = max(0.0, pending_by_item[key]) + if transfer_qty > pending_qty: + item = first_row_by_item[key] + frappe.throw( + _( + "Row #{0}: Cannot transfer {1} {2} of Item {3}. " + "Maximum transferable quantity is {4} {2}." + ).format( + item.idx, + transfer_qty, + item.uom, + frappe.bold(item.item_code), + pending_qty, + ), + title=_("Excess Material Transfer"), + ) + def validate_same_source_target_warehouse_during_material_transfer(self): """ Validate Material Transfer entries where source and target warehouses are identical. From e6d45e35c752592223f3c64cec4d4ed7d327aa3d Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Sun, 21 Jun 2026 00:53:53 +0530 Subject: [PATCH 107/132] test(stock): add test to validate the partial transfer of raw material (cherry picked from commit 4d055d374a5ca82b2dacc99b51cfe0c383401d5e) --- .../doctype/work_order/test_work_order.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index bf56182ee41..2679d6e29fe 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -1461,6 +1461,68 @@ class TestWorkOrder(FrappeTestCase): self.assertEqual(work_order.required_items[0].transferred_qty, 1) self.assertEqual(work_order.required_items[1].transferred_qty, 2) + def test_material_transferred_min_fraction_on_partial_pick_list(self): + """Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing' + must reflect the least-transferred required item (the bottleneck), instead of being + marked fully transferred prematurely when only some materials are transferred. + """ + work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0 + ) + + required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items} + + # pick-list transfer: For Quantity = 0 + transfer_entry = frappe.get_doc( + make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0) + ) + self.assertEqual(transfer_entry.fg_completed_qty, 0.0) + + for item in transfer_entry.items: + full_qty = required_qty[item.item_code] + item.qty = full_qty if item.item_code == "_Test Item" else full_qty / 2 + item.transfer_qty = item.qty + transfer_entry.submit() + + work_order.reload() + transferred_qty = {row.item_code: flt(row.transferred_qty) for row in work_order.required_items} + self.assertEqual(transferred_qty["_Test Item"], required_qty["_Test Item"]) + self.assertEqual( + transferred_qty["_Test Item Home Desktop 100"], + required_qty["_Test Item Home Desktop 100"] / 2, + ) + # bottleneck fraction = 0.5 -> 0.5 * qty(2) = 1.0 + self.assertEqual(work_order.material_transferred_for_manufacturing, 1.0) + + def test_material_transferred_full_via_pick_list_flow(self): + """Pick-list flow with every required item fully transferred marks the work order + as fully transferred (min fraction = 1.0).""" + work_order = make_wo_order_test_record(planned_start_date=now(), qty=2) + test_stock_entry.make_stock_entry( + item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0 + ) + test_stock_entry.make_stock_entry( + item_code="_Test Item Home Desktop 100", target="_Test Warehouse - _TC", qty=10, basic_rate=1000.0 + ) + + required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items} + + transfer_entry = frappe.get_doc( + make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0) + ) + self.assertEqual(transfer_entry.fg_completed_qty, 0.0) + for item in transfer_entry.items: + item.qty = required_qty[item.item_code] + item.transfer_qty = item.qty + transfer_entry.submit() + + work_order.reload() + self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0) + def test_backflushed_batch_raw_materials_based_on_transferred(self): frappe.db.set_single_value( "Manufacturing Settings", From 42af4ce7b005f1dc28d5590fc637478bcb540a2b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:00:50 +0530 Subject: [PATCH 108/132] fix: escape user image url on various templates (backport #56269) (#56270) Co-authored-by: Diptanil Saha fix: escape user image url on various templates (#56269) --- erpnext/templates/emails/daily_project_summary.html | 2 +- erpnext/templates/includes/projects/project_row.html | 2 +- erpnext/templates/includes/projects/project_tasks.html | 2 +- erpnext/templates/includes/projects/project_timesheets.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/templates/emails/daily_project_summary.html b/erpnext/templates/emails/daily_project_summary.html index 5ccc6101665..689a503a7d1 100644 --- a/erpnext/templates/emails/daily_project_summary.html +++ b/erpnext/templates/emails/daily_project_summary.html @@ -10,7 +10,7 @@ {% if user.image %} - + {% else %}
{{ user.full_name[0] }} diff --git a/erpnext/templates/includes/projects/project_row.html b/erpnext/templates/includes/projects/project_row.html index ccb306afcdb..7c8d23f056d 100644 --- a/erpnext/templates/includes/projects/project_row.html +++ b/erpnext/templates/includes/projects/project_row.html @@ -34,7 +34,7 @@ ], as_dict = True) %} {% if user_details.user_image %} - + {% else %} diff --git a/erpnext/templates/includes/projects/project_tasks.html b/erpnext/templates/includes/projects/project_tasks.html index 4069d3dcca5..43fa591cd47 100644 --- a/erpnext/templates/includes/projects/project_tasks.html +++ b/erpnext/templates/includes/projects/project_tasks.html @@ -27,7 +27,7 @@ as_dict = True)%} {% if user_details.user_image %} - + {% else %} diff --git a/erpnext/templates/includes/projects/project_timesheets.html b/erpnext/templates/includes/projects/project_timesheets.html index b8e06829192..6c3f8011ecd 100644 --- a/erpnext/templates/includes/projects/project_timesheets.html +++ b/erpnext/templates/includes/projects/project_timesheets.html @@ -15,7 +15,7 @@ %} {% if user_details.user_image %} - + {% else %} From 37d26222d709d013d184f55bf50dedac9bd94aa1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:17:49 +0530 Subject: [PATCH 109/132] fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (backport #55903) (#56169) fix: clear stale payment rows on non-POS returns so they don't surface in bank reconciliation (#55903) (cherry picked from commit 322d4dff25fb3e73da70e5bd5c49478b97866854) # Conflicts: # erpnext/accounts/doctype/sales_invoice/sales_invoice.py # erpnext/accounts/doctype/sales_invoice/services/pos.py Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- .../doctype/sales_invoice/sales_invoice.py | 9 ++++++++- .../doctype/sales_invoice/test_sales_invoice.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5d6676aa537..97b2afd7751 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -455,8 +455,8 @@ class SalesInvoice(SellingController): self.calculate_taxes_and_totals() def before_save(self): - self.set_account_for_mode_of_payment() self.set_paid_amount() + self.set_account_for_mode_of_payment() def before_submit(self): self.add_remarks() @@ -791,6 +791,13 @@ class SalesInvoice(SellingController): def set_paid_amount(self): paid_amount = 0.0 base_paid_amount = 0.0 + + if not cint(self.is_pos) and self.is_return: + self.set("payments", []) + self.paid_amount = paid_amount + self.base_paid_amount = base_paid_amount + return + for data in self.payments: data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount")) paid_amount += data.amount diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index e210903c450..6f51e27f532 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1049,6 +1049,21 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(pos_return.get("payments")[0].amount, -500) self.assertEqual(pos_return.get("payments")[1].amount, -500) + def test_non_pos_return_clears_payment_rows(self): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + si = create_sales_invoice(do_not_save=True) + si.append("payments", {"mode_of_payment": "Cash", "amount": 100}) + si.insert() + si.submit() + + si_return = make_sales_return(si.name) + si_return.insert() + + self.assertEqual(si_return.is_pos, 0) + self.assertEqual(si_return.get("payments"), []) + self.assertEqual(si_return.paid_amount, 0) + def test_pos_change_amount(self): make_pos_profile( company="_Test Company with perpetual inventory", From 1f5283da5891be08983ec092cc6e9e9fe8ed5bb2 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Mon, 22 Jun 2026 11:47:36 +0530 Subject: [PATCH 110/132] fix: add partially transferred status and fix button visibility for partial material transfer on job card --- .../doctype/job_card/job_card.js | 12 ++++------ .../doctype/job_card/job_card.json | 4 ++-- .../doctype/job_card/job_card.py | 23 +++++++++++++++++-- .../doctype/job_card/job_card_list.js | 1 + 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index e096c73cc61..cc8bdf04176 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -72,8 +72,9 @@ frappe.ui.form.on("Job Card", { frm.toggle_enable("for_quantity", !has_stock_entry); if (!frm.is_new() && has_items && frm.doc.docstatus < 2) { - let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; - let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; + const excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; + const to_transfer = frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty)); + const to_request = to_transfer; if (to_request || excess_transfer_allowed) { frm.add_custom_button( @@ -85,10 +86,6 @@ frappe.ui.form.on("Job Card", { ); } - // check if any row has untransferred materials - // in case of multiple items in JC - let to_transfer = frm.doc.items.some((row) => row.transferred_qty < row.required_qty); - if (to_transfer || excess_transfer_allowed) { frm.add_custom_button( __("Material Transfer"), @@ -120,7 +117,8 @@ frappe.ui.form.on("Job Card", { frm.doc.docstatus == 0 && !frm.is_new() && (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && - (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty) + (!frm.doc.items.length || + !frm.doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty))) ) { // if Job Card is link to Work Order, the job card must not be able to start if Work Order not "Started" // and if stock mvt for WIP is required diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index ba680df99f9..45ddb9cf609 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -234,7 +234,7 @@ "fieldtype": "Select", "label": "Status", "no_copy": 1, - "options": "Open\nWork In Progress\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", + "options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted", "read_only": 1 }, { @@ -513,7 +513,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2026-05-12 12:17:17.750857", + "modified": "2026-06-22 11:51:16.526778", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index ae60bcb1ca8..9fddeda3e96 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -107,6 +107,7 @@ class JobCard(Document): status: DF.Literal[ "Open", "Work In Progress", + "Partially Transferred", "Material Transferred", "On Hold", "Submitted", @@ -927,6 +928,8 @@ class JobCard(Document): frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty)) + self.set_status(update_status=True) + def set_transferred_qty(self, update_status=False): "Set total FG Qty in Job Card for which RM was transferred." if not self.items: @@ -980,7 +983,22 @@ class JobCard(Document): self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0] if self.docstatus < 2: - if flt(self.for_quantity) <= flt(self.transferred_qty): + if self.items: + item_data = frappe.get_all( + "Job Card Item", + filters={"parent": self.name}, + fields=["transferred_qty", "required_qty"], + ) + all_transferred = item_data and all( + flt(d.transferred_qty) >= flt(d.required_qty) for d in item_data + ) + any_transferred = any(flt(d.transferred_qty) > 0 for d in item_data) + + if all_transferred: + self.status = "Material Transferred" + elif any_transferred: + self.status = "Partially Transferred" + elif flt(self.for_quantity) <= flt(self.transferred_qty): self.status = "Material Transferred" if self.time_logs: @@ -1224,12 +1242,13 @@ def time_diff_in_minutes(string_ed_date, string_st_date): @frappe.whitelist() -def get_job_details(start, end, filters=None): +def get_job_details(start: str, end: str, filters: str | None = None): events = [] event_color = { "Completed": "#cdf5a6", "Material Transferred": "#ffdd9e", + "Partially Transferred": "#ffe5b4", "Work In Progress": "#D3D3D3", } diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js index e417b7f576d..bddb11d00bb 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card_list.js +++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js @@ -7,6 +7,7 @@ frappe.listview_settings["Job Card"] = { Completed: "green", Cancelled: "red", "Material Transferred": "blue", + "Partially Transferred": "yellow", Open: "red", }; const status = doc.status || "Open"; From 7bea925230e179e4bda2862abe219736f3877821 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Sat, 30 May 2026 18:21:11 +0530 Subject: [PATCH 111/132] fix: add validation and tests for set_status (cherry picked from commit b5a84c5e65f566c98d5b3d31c362e5b3983a839c) --- erpnext/support/doctype/issue/issue.py | 1 - erpnext/support/doctype/issue/test_issue.py | 32 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index c35b76cf37d..a08c9b22c23 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -226,7 +226,6 @@ def set_multiple_status(names, status): @frappe.whitelist() def set_status(name, status): frappe.has_permission("Issue", "write", name, throw=True) - frappe.db.set_value("Issue", name, "status", status) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index c334a2251b3..09ae0d516bb 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -524,6 +524,38 @@ class TestFirstResponseTime(TestSetUp): ) self.assertEqual(issue.first_response_time, 1.0) + def _get_no_perm_user(self): + email = "test_no_issue_perm@example.com" + if not frappe.db.exists("User", email): + user = frappe.new_doc("User") + user.email = email + user.first_name = "No Perm" + user.send_welcome_email = 0 + user.insert(ignore_permissions=True) + return email + + def test_set_status_requires_write_permission(self): + from erpnext.support.doctype.issue.issue import set_status + + issue = frappe.new_doc("Issue") + issue.subject = "_Test Permission Issue" + issue.insert(ignore_permissions=True) + frappe.set_user(self._get_no_perm_user()) + self.assertRaises(frappe.PermissionError, set_status, issue.name, "Closed") + frappe.set_user("Administrator") + + def test_set_multiple_status_requires_write_permission(self): + import json + + from erpnext.support.doctype.issue.issue import set_multiple_status + + issue = frappe.new_doc("Issue") + issue.subject = "_Test Permission Issue" + issue.insert(ignore_permissions=True) + frappe.set_user(self._get_no_perm_user()) + self.assertRaises(frappe.PermissionError, set_multiple_status, json.dumps([issue.name]), "Closed") + frappe.set_user("Administrator") + def create_issue_and_communication(issue_creation, first_responded_on): issue = make_issue(issue_creation, index=1) From 42121f2e36d314166ee3f7e92e37cc3a5a2dfa3b Mon Sep 17 00:00:00 2001 From: ljain112 Date: Mon, 22 Jun 2026 17:40:57 +0530 Subject: [PATCH 112/132] fix: simplify get_round_off_applicable_accounts function signature --- erpnext/controllers/taxes_and_totals.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 09318b6725f..1fc8128d6ea 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -1130,9 +1130,7 @@ def get_itemised_tax_breakup_html(doc): @frappe.whitelist() -def get_round_off_applicable_accounts( - company: str, account_list: list | str, doc: str | dict | Document | None = None -): +def get_round_off_applicable_accounts(company, account_list, doc=None): # required to set correct region with temporary_flag("company", company): return get_regional_round_off_accounts(company, account_list, doc) From 506658c3a6941eff92afb7bbe15c68603b836c26 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:49:58 +0530 Subject: [PATCH 113/132] fix: attribute error because of missing margin_type field in Supplier Quotation (backport #48089) * fix: add missing margin related fields (cherry picked from commit 67f96c66e40ecce72b1131e05ef7dd17c3aa6201) # Conflicts: # erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json * chore: fix conflict --------- Co-authored-by: iamkhanraheel Co-authored-by: Sagar Vora <16315650+sagarvora@users.noreply.github.com> --- .../supplier_quotation_item.json | 51 ++++++++++++++++--- .../supplier_quotation_item.py | 3 ++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json index 91019104949..a41638966f1 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.json @@ -30,11 +30,15 @@ "stock_qty", "sec_break_price_list", "price_list_rate", + "base_price_list_rate", + "discount_and_margin_section", + "margin_type", + "margin_rate_or_amount", + "rate_with_margin", + "col_break_6", "discount_percentage", "discount_amount", "distributed_discount_amount", - "col_break_price_list", - "base_price_list_rate", "sec_break1", "rate", "amount", @@ -531,10 +535,6 @@ "fieldname": "sec_break_price_list", "fieldtype": "Section Break" }, - { - "fieldname": "col_break_price_list", - "fieldtype": "Column Break" - }, { "collapsible": 1, "fieldname": "ad_sec_break", @@ -572,13 +572,48 @@ "fieldtype": "Currency", "label": "Distributed Discount Amount", "options": "currency" + }, + { + "depends_on": "price_list_rate", + "fieldname": "margin_type", + "fieldtype": "Select", + "label": "Margin Type", + "options": "\nPercentage\nAmount", + "print_hide": 1 + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate", + "fieldname": "margin_rate_or_amount", + "fieldtype": "Float", + "label": "Margin Rate or Amount", + "print_hide": 1 + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.margin_type || doc.discount_amount || doc.distributed_discount_amount", + "fieldname": "discount_and_margin_section", + "fieldtype": "Section Break", + "label": "Discount and Margin" + }, + { + "depends_on": "eval:doc.margin_type && doc.price_list_rate && doc.margin_rate_or_amount", + "fieldname": "rate_with_margin", + "fieldtype": "Currency", + "label": "Rate With Margin", + "options": "currency", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "col_break_6", + "fieldtype": "Column Break" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-06-02 06:22:18.864822", + "modified": "2025-06-17 12:05:52.441645", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation Item", @@ -589,4 +624,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py index a51b9500fd8..ac313cbf182 100644 --- a/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py +++ b/erpnext/buying/doctype/supplier_quotation_item/supplier_quotation_item.py @@ -38,6 +38,8 @@ class SupplierQuotationItem(Document): lead_time_days: DF.Int manufacturer: DF.Link | None manufacturer_part_no: DF.Data | None + margin_rate_or_amount: DF.Float + margin_type: DF.Literal["", "Percentage", "Amount"] material_request: DF.Link | None material_request_item: DF.Data | None net_amount: DF.Currency @@ -52,6 +54,7 @@ class SupplierQuotationItem(Document): project: DF.Link | None qty: DF.Float rate: DF.Currency + rate_with_margin: DF.Currency request_for_quotation: DF.Link | None request_for_quotation_item: DF.Data | None sales_order: DF.Link | None From f106513005265c6b0bc06c79eed46e5cfc44d931 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 23 Jun 2026 16:32:16 +0530 Subject: [PATCH 114/132] fix: resolve backport conflict in accounting dashboard number cards Resolved Mergify backport conflicts (PR #55548 / backport of #55484) by taking the final upstream state: fiscal-year posting_date Between filter with calendar-year fallback, Timespan filter removed. Co-Authored-By: Claude Opus 4.8 --- .../total_incoming_bills/total_incoming_bills.json | 13 ------------- .../total_incoming_payment.json | 13 ------------- .../total_outgoing_bills/total_outgoing_bills.json | 13 ------------- .../total_outgoing_payment.json | 13 ------------- 4 files changed, 52 deletions(-) diff --git a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json index 5b545837905..34e42ac7cfe 100644 --- a/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json +++ b/erpnext/accounts/number_card/total_incoming_bills/total_incoming_bills.json @@ -4,27 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Purchase Invoice", -<<<<<<< HEAD -<<<<<<< HEAD - "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", -======= - "dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", -======= "dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", ->>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Bills", -<<<<<<< HEAD - "modified": "2024-11-20 19:08:37.043777", -======= "modified": "2026-06-01 12:00:00.000000", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Bills", diff --git a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json index 4c13e4c1823..d0f125df5bf 100644 --- a/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json +++ b/erpnext/accounts/number_card/total_incoming_payment/total_incoming_payment.json @@ -4,27 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", -<<<<<<< HEAD -<<<<<<< HEAD - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\",false]]", -======= - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", -======= "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", ->>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Incoming Payment", -<<<<<<< HEAD - "modified": "2020-07-22 13:06:20.237689", -======= "modified": "2026-06-01 12:00:00.000000", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Incoming Payment", diff --git a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json index 2f66ad50578..5eff4005fda 100644 --- a/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json +++ b/erpnext/accounts/number_card/total_outgoing_bills/total_outgoing_bills.json @@ -4,27 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Sales Invoice", -<<<<<<< HEAD -<<<<<<< HEAD - "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\",false],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\",false]]", -======= - "dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", -======= "dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", ->>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Bills", -<<<<<<< HEAD - "modified": "2020-07-22 13:07:19.633101", -======= "modified": "2026-06-01 12:00:00.000000", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Bills", diff --git a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json index 6e583d8daf5..a78f73c1dc5 100644 --- a/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json +++ b/erpnext/accounts/number_card/total_outgoing_payment/total_outgoing_payment.json @@ -4,27 +4,14 @@ "docstatus": 0, "doctype": "Number Card", "document_type": "Payment Entry", -<<<<<<< HEAD -<<<<<<< HEAD - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]", - "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\",false],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\",false],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\",false]]", -======= - "dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\",\"posting_date\",\"Between\",\"[frappe.boot.current_fiscal_year[1], frappe.boot.current_fiscal_year[2]]\"]]", -======= "dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]", ->>>>>>> c68918bc18 (fix: set a fallback value if no fiscal year set) "filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "function": "Sum", "idx": 0, "is_public": 1, "is_standard": 1, "label": "Total Outgoing Payment", -<<<<<<< HEAD - "modified": "2020-07-22 12:49:34.942896", -======= "modified": "2026-06-01 12:00:00.000000", ->>>>>>> e8fff2fdad (fix: use fiscal year instead of calendar year in accounting dashboard number cards) "modified_by": "Administrator", "module": "Accounts", "name": "Total Outgoing Payment", From 1b4da9dc961e1aba8091342ba9230fac6f33daed Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 23 Jun 2026 16:54:23 +0530 Subject: [PATCH 115/132] fix(manufacturing): make item_code mandatory in Job Card Item The item_code field in the Job Card Item child table was optional, allowing job cards to be saved without a raw material item linked. Set reqd=1 in the JSON and update the Python type annotation accordingly. (cherry picked from commit d7e9a97f8ad67596e34fb947a42cd8ef08e638d1) --- .../manufacturing/doctype/job_card_item/job_card_item.json | 5 +++-- erpnext/manufacturing/doctype/job_card_item/job_card_item.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index 93a0b8960e5..f21e94ebbe0 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -26,7 +26,8 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Item Code", - "options": "Item" + "options": "Item", + "reqd": 1 }, { "fieldname": "source_warehouse", @@ -107,7 +108,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-05-12 12:22:18.506904", + "modified": "2026-06-23 16:52:37.669110", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py index ecf082392a3..f1b0f53e9c4 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.py +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.py @@ -16,7 +16,7 @@ class JobCardItem(Document): allow_alternative_item: DF.Check description: DF.Text | None - item_code: DF.Link | None + item_code: DF.Link item_group: DF.Link | None item_name: DF.Data | None parent: DF.Data From 9b6adc42b6ca2d4b954aa69000b6cee922a3073a Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Tue, 23 Jun 2026 00:07:53 +0530 Subject: [PATCH 116/132] fix: show contextual balance label on party dashboard for net balances (cherry picked from commit 3251b403658f0f3f851728fb689ed150ef469d56) --- erpnext/accounts/party.py | 9 +++++++++ erpnext/public/js/utils.js | 31 ++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index d02230f0ee7..b39c5a7dc62 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -922,6 +922,15 @@ def get_dashboard_info(party_type, party, loyalty_program=None): if party_type == "Supplier": info["total_unpaid"] = -1 * info["total_unpaid"] + if info["total_unpaid"] < 0: + info["balance_label"] = ( + "Total Advance Paid" if party_type == "Supplier" else "Total Advance Received" + ) + info["balance_amount"] = abs(info["total_unpaid"]) + else: + info["balance_label"] = "Total Unpaid" + info["balance_amount"] = info["total_unpaid"] + company_wise_info.append(info) return company_wise_info diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 3ae0b3d1835..8746aa822aa 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -93,11 +93,19 @@ $.extend(erpnext.utils, { ]), "blue" ); + var info = company_wise_info[0]; + var is_advance = info.balance_label !== "Total Unpaid"; + var indicator_label = + info.balance_label === "Total Advance Paid" + ? __("Total Advance Paid: {0}", [format_currency(info.balance_amount, info.currency)]) + : info.balance_label === "Total Advance Received" + ? __("Total Advance Received: {0}", [ + format_currency(info.balance_amount, info.currency), + ]) + : __("Total Unpaid: {0}", [format_currency(info.balance_amount, info.currency)]); frm.dashboard.add_indicator( - __("Total Unpaid: {0}", [ - format_currency(company_wise_info[0].total_unpaid, company_wise_info[0].currency), - ]), - company_wise_info[0].total_unpaid ? "orange" : "green" + indicator_label, + is_advance ? "green" : info.balance_amount ? "orange" : "green" ); if (company_wise_info[0].loyalty_points) { @@ -140,7 +148,14 @@ $.extend(erpnext.utils, { frm.dashboard.stats_area_row.addClass("flex"); frm.dashboard.stats_area_row.css("flex-wrap", "wrap"); - var color = info.total_unpaid ? "orange" : "green"; + var is_advance = info.balance_label !== "Total Unpaid"; + var color = is_advance ? "green" : info.balance_amount ? "orange" : "green"; + var balance_label_text = + info.balance_label === "Total Advance Paid" + ? __("Total Advance Paid") + : info.balance_label === "Total Advance Received" + ? __("Total Advance Received") + : __("Total Unpaid"); var indicator = $( '
' + @@ -154,8 +169,10 @@ $.extend(erpnext.utils, { '" + "
" ).appendTo(frm.dashboard.stats_area_row); From 66d0a60140888720dfc11838db923667328186a7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:02:47 +0530 Subject: [PATCH 117/132] feat(crm_settings)!: enable frappe crm data synchronization (backport #56268) (#56383) * feat(crm_settings): enable frappe crm data synchronization (#56268) (cherry picked from commit 0ab812c3ec08f7188c166411f05521fe626cbfb3) # Conflicts: # erpnext/crm/frappe_crm_api.py * chore: resolve conflicts --------- Co-authored-by: Diptanil Saha --- .../doctype/crm_settings/crm_settings.json | 42 ++++++++++++++++- .../crm/doctype/crm_settings/crm_settings.py | 46 ++++++++++++++++++ .../frappe_crm_allowed_user/__init__.py | 0 .../frappe_crm_allowed_user.json | 36 ++++++++++++++ .../frappe_crm_allowed_user.py | 23 +++++++++ erpnext/crm/frappe_crm_api.py | 47 +++++++++---------- 6 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py create mode 100644 erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json create mode 100644 erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.json b/erpnext/crm/doctype/crm_settings/crm_settings.json index 28d43c97d29..8822dd7ea02 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.json +++ b/erpnext/crm/doctype/crm_settings/crm_settings.json @@ -20,7 +20,11 @@ "section_break_13", "carry_forward_communication_and_comments", "column_break_junk", - "update_timestamp_on_new_communication" + "update_timestamp_on_new_communication", + "frappe_crm_section", + "enable_frappe_crm_data_synchronization", + "column_break_jbzj", + "allowed_users" ], "fields": [ { @@ -105,13 +109,37 @@ "fieldname": "enable_opportunity_creation_from_contact_us", "fieldtype": "Check", "label": "Enable Opportunity Creation from Contact Us" + }, + { + "fieldname": "frappe_crm_section", + "fieldtype": "Section Break", + "label": "Frappe CRM" + }, + { + "fieldname": "column_break_jbzj", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;", + "fieldname": "allowed_users", + "fieldtype": "Table MultiSelect", + "label": "Allowed Users", + "options": "Frappe CRM Allowed User", + "permlevel": 1 + }, + { + "default": "0", + "fieldname": "enable_frappe_crm_data_synchronization", + "fieldtype": "Check", + "label": "Enable Frappe CRM Data Synchronization", + "permlevel": 1 } ], "icon": "fa fa-cog", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-06-11 23:09:49.750381", + "modified": "2026-06-22 01:26:13.474915", "modified_by": "Administrator", "module": "CRM", "name": "CRM Settings", @@ -145,6 +173,16 @@ "role": "Sales Master Manager", "share": 1, "write": 1 + }, + { + "delete": 1, + "email": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 } ], "sort_field": "modified", diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 01cdaf41bde..04e5a402add 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields from frappe.model.document import Document @@ -15,12 +16,16 @@ class CRMSettings(Document): if TYPE_CHECKING: from frappe.types import DF + from erpnext.crm.doctype.frappe_crm_allowed_user.frappe_crm_allowed_user import FrappeCRMAllowedUser + allow_lead_duplication_based_on_emails: DF.Check + allowed_users: DF.TableMultiSelect[FrappeCRMAllowedUser] auto_creation_of_contact: DF.Check campaign_naming_by: DF.Literal["Campaign Name", "Naming Series"] carry_forward_communication_and_comments: DF.Check close_opportunity_after_days: DF.Int default_valid_till: DF.Data | None + enable_frappe_crm_data_synchronization: DF.Check enable_opportunity_creation_from_contact_us: DF.Check update_timestamp_on_new_communication: DF.Check # end: auto-generated types @@ -28,6 +33,7 @@ class CRMSettings(Document): def validate(self): frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", "")) self.validate_enable_opportunity_creation_from_contact_us() + self.validate_allowed_users() def validate_enable_opportunity_creation_from_contact_us(self): contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled") @@ -38,3 +44,43 @@ class CRMSettings(Document): "Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled." ) ) + + def validate_allowed_users(self): + if self.enable_frappe_crm_data_synchronization and not self.allowed_users: + frappe.throw( + _( + "Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site." + ) + ) + + def before_save(self): + self.clear_allowed_users() + + def on_update(self): + self.custom_fields_for_frappe_crm_data_sync() + + def clear_allowed_users(self): + if not self.enable_frappe_crm_data_synchronization: + self.allowed_users = [] + + def custom_fields_for_frappe_crm_data_sync(self): + custom_fields = { + "Quotation": [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "insert_after": "party_name", + } + ], + "Customer": [ + { + "fieldname": "crm_deal", + "fieldtype": "Data", + "label": "Frappe CRM Deal", + "insert_after": "prospect_name", + } + ], + } + + create_custom_fields(custom_fields, ignore_validate=True) diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py b/erpnext/crm/doctype/frappe_crm_allowed_user/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json new file mode 100644 index 00000000000..403a5573460 --- /dev/null +++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.json @@ -0,0 +1,36 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "allow_rename": 1, + "creation": "2026-06-22 00:47:12.265968", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-22 01:49:54.586410", + "modified_by": "Administrator", + "module": "CRM", + "name": "Frappe CRM Allowed User", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py new file mode 100644 index 00000000000..0cbc5b8ab76 --- /dev/null +++ b/erpnext/crm/doctype/frappe_crm_allowed_user/frappe_crm_allowed_user.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class FrappeCRMAllowedUser(Document): + # 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 + + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + user: DF.Link + # end: auto-generated types + + _DOCTYPE_NAME = "Frappe CRM Allowed User" diff --git a/erpnext/crm/frappe_crm_api.py b/erpnext/crm/frappe_crm_api.py index 586ebe91eed..5db9b7dc652 100644 --- a/erpnext/crm/frappe_crm_api.py +++ b/erpnext/crm/frappe_crm_api.py @@ -2,35 +2,12 @@ import json import frappe from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields - - -@frappe.whitelist() -def create_custom_fields_for_frappe_crm(): - frappe.only_for("System Manager") - custom_fields = { - "Quotation": [ - { - "fieldname": "crm_deal", - "fieldtype": "Data", - "label": "Frappe CRM Deal", - "insert_after": "party_name", - } - ], - "Customer": [ - { - "fieldname": "crm_deal", - "fieldtype": "Data", - "label": "Frappe CRM Deal", - "insert_after": "prospect_name", - } - ], - } - create_custom_fields(custom_fields, ignore_validate=True) @frappe.whitelist() def create_prospect_against_crm_deal(): + validate_frappe_crm_sync() + doc = frappe.form_dict prospect = frappe.new_doc("Prospect") prospect.company_name = doc.organization or doc.lead_name @@ -161,6 +138,8 @@ CUSTOMER_ALLOWED_FIELDS = { @frappe.whitelist() def create_customer(customer_data=None): + validate_frappe_crm_sync() + if not customer_data: customer_data = frappe.form_dict @@ -181,3 +160,21 @@ def create_customer(customer_data=None): except Exception: frappe.log_error(frappe.get_traceback(), "Error while creating customer against Frappe CRM Deal") pass + + +def validate_frappe_crm_sync(): + CRMSettings = frappe.get_single("CRM Settings") + if not CRMSettings.enable_frappe_crm_data_synchronization: + frappe.throw( + _("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.") + ) + + allowed_users = [d.user for d in CRMSettings.allowed_users] + + if frappe.session.user not in allowed_users: + frappe.throw( + _( + "User not allowed to synchronize data from Frappe CRM on ERPNext. Contact System Manager of ERPNext." + ), + exc=frappe.PermissionError, + ) From 53a11229ec4dd2971375ec701ae240cac2331674 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:49:07 +0000 Subject: [PATCH 118/132] fix(budget): ambiguous error message for budget assignment validation (backport #56390) (#56391) Co-authored-by: Wolfram Schmidt Co-authored-by: Diptanil Saha fix(budget): ambiguous error message for budget assignment validation (#56390) --- erpnext/accounts/doctype/budget/budget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/budget/budget.py b/erpnext/accounts/doctype/budget/budget.py index 4ef1f164aaa..e1038b6af33 100644 --- a/erpnext/accounts/doctype/budget/budget.py +++ b/erpnext/accounts/doctype/budget/budget.py @@ -103,8 +103,8 @@ class Budget(Document): elif account_details.report_type != "Profit and Loss": frappe.throw( _( - "Budget cannot be assigned against {0}, as it's not an Income or Expense account" - ).format(d.account) + "Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense" + ).format(self.account) ) if d.account in account_list: From cef608d043b0198aa57fd55f6bff7d85b7cea7b6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:10:54 +0530 Subject: [PATCH 119/132] fix(payment_entry): recompute base amount when exchange rate changes (backport #56136) (#56397) Co-authored-by: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com> Co-authored-by: ervishnucs fix(payment_entry): recompute base amount when exchange rate changes (#56136) --- .../doctype/payment_entry/payment_entry.js | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 14fc2b51b19..5a5815ae4c7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -769,17 +769,21 @@ frappe.ui.form.on("Payment Entry", { frm.set_paid_amount_based_on_received_amount = true; let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) { - frm.set_value("base_paid_amount", frm.doc.base_received_amount); + if (frm.doc.paid_amount && frm.doc.source_exchange_rate) { + frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); + frm.set_value("base_received_amount", frm.doc.base_paid_amount); // target exchange rate should always be same as source if both account currencies is same if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate); + frm.set_value("received_amount", frm.doc.paid_amount); } else { - frm.set_value( - "paid_amount", - flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate) - ); + const target_rate = + flt(frm.doc.target_exchange_rate) || + (company_currency == frm.doc.paid_to_account_currency ? 1 : 0); + if (target_rate) { + frm.set_value("received_amount", flt(frm.doc.base_received_amount) / target_rate); + } } // set_unallocated_amount is called by below method, @@ -795,18 +799,23 @@ frappe.ui.form.on("Payment Entry", { target_exchange_rate: function (frm) { let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) { - frm.set_value("base_received_amount", frm.doc.base_paid_amount); - if ( - !frm.doc.source_exchange_rate && - frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency - ) { + if (frm.doc.received_amount && frm.doc.target_exchange_rate) { + frm.set_value( + "base_received_amount", + flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate) + ); + frm.set_value("base_paid_amount", frm.doc.base_received_amount); + + if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); + frm.set_value("paid_amount", frm.doc.received_amount); } else { - frm.set_value( - "received_amount", - flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate) - ); + const source_rate = + flt(frm.doc.source_exchange_rate) || + (company_currency == frm.doc.paid_from_account_currency ? 1 : 0); + if (source_rate) { + frm.set_value("paid_amount", flt(frm.doc.base_paid_amount) / source_rate); + } } // set_unallocated_amount is called by below method, From 334a0b2137d75a5cc182b4c261617e0f26b770be Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:24:29 +0530 Subject: [PATCH 120/132] fix(pos): remove redundant opening balance dialog onchange handler (backport #54591) (#56402) Co-authored-by: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com> fix(pos): remove redundant opening balance dialog onchange handler (#54591) --- erpnext/selling/page/point_of_sale/pos_controller.js | 9 --------- 1 file changed, 9 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index e232464b53a..f042718045c 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -40,15 +40,6 @@ erpnext.PointOfSale.Controller = class { in_list_view: 1, label: __("Opening Amount"), options: "company:company_currency", - onchange: function () { - dialog.fields_dict.balance_details.df.data.some((d) => { - if (d.idx == this.doc.idx) { - d.opening_amount = this.value; - dialog.fields_dict.balance_details.grid.refresh(); - return true; - } - }); - }, }, ]; const fetch_pos_payment_methods = () => { From c97be8abe1b6ca1115130776b52065d6eddf914c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 12:57:31 +0530 Subject: [PATCH 121/132] feat(accounts): add configurable job timeout for Process Period Closing Voucher Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings so admins can tune the enqueue timeout for PCV background jobs without a code change. All three `frappe.enqueue` calls in `process_period_closing_voucher.py` now read this value at runtime. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 13b6c4a165db29d95d05cdcd01d79cdeaca0accc) # Conflicts: # erpnext/accounts/doctype/accounts_settings/accounts_settings.json --- .../accounts_settings/accounts_settings.json | 16 ++++++++++++++++ .../process_period_closing_voucher.py | 10 +++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 18a0a86fd52..bfde962fe62 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -79,6 +79,7 @@ "acc_frozen_upto", "ignore_account_closing_balance", "use_legacy_controller_for_pcv", + "pcv_job_timeout", "column_break_25", "frozen_accounts_modifier", "tab_break_dpet", @@ -635,6 +636,7 @@ "label": "Use Legacy Controller For Period Closing Voucher" }, { +<<<<<<< HEAD "fieldname": "payment_entry_settings", "fieldtype": "Section Break", "label": "Payment Entry Settings" @@ -650,6 +652,20 @@ "fieldname": "show_party_balance", "fieldtype": "Check", "label": "Show Party Balance" +======= + "default": "3600", + "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", + "fieldname": "pcv_job_timeout", + "fieldtype": "Int", + "label": "PCV Job Timeout (seconds)" + }, + { + "description": "Users with this role will be notified if the asset depreciation gets failed", + "fieldname": "role_to_notify_on_depreciation_failure", + "fieldtype": "Link", + "label": "Role to Notify on Depreciation Failure", + "options": "Role" +>>>>>>> 13b6c4a165 (feat(accounts): add configurable job timeout for Process Period Closing Voucher) }, { "default": "30, 60, 90, 120", 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 e6a330a4d14..096b085cf0e 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 @@ -92,6 +92,8 @@ def start_pcv_processing(docname: str): frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if normal_balances := ( qb.from_(ppcvd) @@ -118,7 +120,7 @@ def start_pcv_processing(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -244,6 +246,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if to_process := ( qb.from_(ppcvd) @@ -269,7 +273,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -299,7 +303,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", queue="long", - timeout="3600", + timeout=timeout, is_async=True, job_name=job_name, enqueue_after_commit=True, From c33d7e5d7be12f103ccae69354ab75d309af3d2d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 13:01:42 +0530 Subject: [PATCH 122/132] refactor: patch, display depends on and json changes (cherry picked from commit 3da7eefebb37bb1f1e6cc4c7c0c2add735e0e2c5) # Conflicts: # erpnext/accounts/doctype/accounts_settings/accounts_settings.json # erpnext/accounts/doctype/accounts_settings/accounts_settings.py # erpnext/patches.txt --- .../doctype/accounts_settings/accounts_settings.json | 5 +++++ .../doctype/accounts_settings/accounts_settings.py | 5 +++++ erpnext/patches.txt | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index bfde962fe62..6dd3c5fcc6e 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -654,6 +654,7 @@ "label": "Show Party Balance" ======= "default": "3600", + "depends_on": "eval: !doc.use_legacy_controller_for_pcv", "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", "fieldname": "pcv_job_timeout", "fieldtype": "Int", @@ -679,7 +680,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2026-05-18 12:16:33.679345", +======= + "modified": "2026-06-24 12:59:41.868865", +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 5cd4955cdd7..69e3dde73af 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -59,7 +59,12 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency +<<<<<<< HEAD post_change_gl_entries: DF.Check +======= + pcv_job_timeout: DF.Int + preview_mode: DF.Check +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3b0ac5cf552..fb3279255e2 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,5 +435,15 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields +<<<<<<< HEAD erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes +======= +erpnext.patches.v16_0.rename_secondary_item_type_field +erpnext.patches.v16_0.submit_existing_product_bundles #1 +erpnext.patches.v16_0.migrate_subscription_generate_invoice_at +erpnext.patches.v16_0.rename_subscription_billing_period_fields +erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb +erpnext.patches.v16_0.set_default_close_opportunity_after_days +execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) From df3c821f98045f9ac038b1bafbd35113949db783 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 15:05:41 +0530 Subject: [PATCH 123/132] chore: resolve conflicts --- .../accounts_settings/accounts_settings.json | 16 ++-------------- .../accounts_settings/accounts_settings.py | 4 ---- erpnext/patches.txt | 9 --------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 6dd3c5fcc6e..62b6d5c0b6a 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -636,7 +636,6 @@ "label": "Use Legacy Controller For Period Closing Voucher" }, { -<<<<<<< HEAD "fieldname": "payment_entry_settings", "fieldtype": "Section Break", "label": "Payment Entry Settings" @@ -652,7 +651,8 @@ "fieldname": "show_party_balance", "fieldtype": "Check", "label": "Show Party Balance" -======= + }, + { "default": "3600", "depends_on": "eval: !doc.use_legacy_controller_for_pcv", "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", @@ -660,14 +660,6 @@ "fieldtype": "Int", "label": "PCV Job Timeout (seconds)" }, - { - "description": "Users with this role will be notified if the asset depreciation gets failed", - "fieldname": "role_to_notify_on_depreciation_failure", - "fieldtype": "Link", - "label": "Role to Notify on Depreciation Failure", - "options": "Role" ->>>>>>> 13b6c4a165 (feat(accounts): add configurable job timeout for Process Period Closing Voucher) - }, { "default": "30, 60, 90, 120", "fieldname": "default_ageing_range", @@ -680,11 +672,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2026-05-18 12:16:33.679345", -======= "modified": "2026-06-24 12:59:41.868865", ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 69e3dde73af..83ece261895 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -59,12 +59,8 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency -<<<<<<< HEAD post_change_gl_entries: DF.Check -======= pcv_job_timeout: DF.Int - preview_mode: DF.Check ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int diff --git a/erpnext/patches.txt b/erpnext/patches.txt index fb3279255e2..b48f16a7550 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,15 +435,6 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields -<<<<<<< HEAD erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes -======= -erpnext.patches.v16_0.rename_secondary_item_type_field -erpnext.patches.v16_0.submit_existing_product_bundles #1 -erpnext.patches.v16_0.migrate_subscription_generate_invoice_at -erpnext.patches.v16_0.rename_subscription_billing_period_fields -erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb -erpnext.patches.v16_0.set_default_close_opportunity_after_days execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) From 25857125007861e121e505eef841f5b8e0233f90 Mon Sep 17 00:00:00 2001 From: Loic Oberle Date: Sat, 23 May 2026 07:59:09 +0200 Subject: [PATCH 124/132] =?UTF-8?q?refactor(sales=5Fperson=5Fwise=5Ftransa?= =?UTF-8?q?ction=5Fsummary):=20Replace=20SQL=20with=20que=E2=80=A6=20(#551?= =?UTF-8?q?91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit df3d0859a13b00d9aa9377dac48cfc365e557b20) --- .../sales_person_wise_transaction_summary.py | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) 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 405159215cd..23ed83cca84 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 @@ -4,7 +4,7 @@ import frappe from frappe import _, msgprint, qb -from frappe.query_builder import Criterion +from frappe.query_builder import Case, Criterion from erpnext import get_company_currency @@ -155,50 +155,60 @@ def get_columns(filters): def get_entries(filters): - date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" - if filters["doc_type"] == "Sales Order": - qty_field = "delivered_qty" - else: - qty_field = "qty" - conditions, values = get_conditions(filters, date_field) + doc_type = filters["doc_type"] - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code, - st.sales_person, st.allocated_percentage, dt_item.warehouse, - CASE - WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor - ELSE dt_item.stock_qty - END as stock_qty, - CASE - WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) - ELSE dt_item.base_net_amount - END as base_net_amount, - CASE - WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100) - ELSE dt_item.base_net_amount * st.allocated_percentage/100 - END as contribution_amt - FROM - `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st - WHERE - st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {} - and dt.docstatus = 1 {} order by st.sales_person, dt.name desc - """.format( - date_field, - qty_field, - qty_field, - qty_field, - filters["doc_type"], - filters["doc_type"], - "%s", - conditions, - ), - tuple([filters["doc_type"], *values]), - as_dict=1, + date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date" + qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty" + + dt = frappe.qb.DocType(doc_type) + dt_item = frappe.qb.DocType(f"{doc_type} Item") + st = frappe.qb.DocType("Sales Team") + + calc_qty = dt_item[qty_field] * dt_item.conversion_factor + calc_net_amount = dt_item.base_net_rate * calc_qty + + stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty") + + base_net_amount_case = ( + Case() + .when(dt.status == "Closed", calc_net_amount) + .else_(dt_item.base_net_amount) + .as_("base_net_amount") ) - return entries + contribution_amt_case = ( + Case() + .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100)) + .else_(dt_item.base_net_amount * st.allocated_percentage / 100) + .as_("contribution_amt") + ) + + query = ( + frappe.get_query(dt, filters=filters, ignore_permissions=False) + .join(dt_item) + .on(dt.name == dt_item.parent) + .join(st) + .on(dt.name == st.parent) + .select( + dt.name, + dt.customer, + dt.territory, + dt[date_field].as_("posting_date"), + dt_item.item_code, + st.sales_person, + st.allocated_percentage, + dt_item.warehouse, + stock_qty_case, + base_net_amount_case, + contribution_amt_case, + ) + .where(st.parenttype == doc_type) + .where(dt.docstatus == 1) + ) + + query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) + + return query.run(as_dict=True) def get_conditions(filters, date_field): From db8a26a3afe971355df746c12d9377e853e6731a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:30:14 +0530 Subject: [PATCH 125/132] fix: precision issue causing COGS in inter transfer PR (backport #56420) (#56424) fix: precision issue causing COGS in inter transfer PR (#56420) (cherry picked from commit 9b0e1b61f202e33beaff8b8a146d3336001dc861) Co-authored-by: rohitwaghchaure --- erpnext/controllers/taxes_and_totals.py | 2 +- .../doctype/repost_item_valuation/repost_item_valuation.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1fc8128d6ea..e218e9a44cb 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -185,7 +185,7 @@ class calculate_taxes_and_totals: return if not self.discount_amount_applied: - do_not_round_fields = ["valuation_rate", "incoming_rate"] + do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"] for item in self.doc.items: self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) 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 ef42da67668..98a2720e6d2 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -306,6 +306,9 @@ class RepostItemValuation(Document): def _recalculate_valuation_rate(self): doc = frappe.get_doc(self.voucher_type, self.voucher_no) + if doc.get("is_internal_supplier"): + doc.set_sales_incoming_rate_for_internal_transfer() + doc.update_valuation_rate() for item in doc.items: item.db_set("valuation_rate", item.valuation_rate) From 414b3665c1cc444e66eb3a3cbb09682d4bf04f78 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Thu, 25 Jun 2026 00:49:27 +0530 Subject: [PATCH 126/132] fix(company): using `String.prototype.bold` method instead of `frappe.utils.bold` on company deletion prompt (#56441) --- erpnext/setup/doctype/company/company.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 33a7ecc8714..799686fbbb1 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -208,7 +208,7 @@ frappe.ui.form.on("Company", { reqd: 1, description: __( "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)] + [frappe.utils.escape_html(frm.doc.name).bold()] ), }, function (data) { @@ -228,7 +228,9 @@ frappe.ui.form.on("Company", { }, }); }, - __("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]), + __("Delete all the Transactions for {0}", [ + frappe.utils.escape_html(frm.doc.name).bold(), + ]), __("Delete") ); d.get_primary_btn().addClass("btn-danger"); From b176fb292b8d0ef3a9f14ec0007578881a1f45ff Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 25 Jun 2026 14:55:47 +0530 Subject: [PATCH 127/132] fix: remove frappe.utils from jinja context in process statement of accounts (cherry picked from commit 37ec2d0edd439a6dd49c4506d889b0977dcfb183) --- .../process_statement_of_accounts.py | 1 - 1 file changed, 1 deletion(-) 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 bf130db8e22..48349a4bd99 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 @@ -390,7 +390,6 @@ def get_context(customer, doc): return { "doc": template_doc, "customer": frappe.get_doc("Customer", customer), - "frappe": frappe.utils, } From 28e4bca4f2ee9394dc8b4a7266b641d0ad58cec9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:41:41 +0000 Subject: [PATCH 128/132] fix(crm): using `get_list` instead of `get_all` in `get_opportunities` (backport #56463) (#56465) Co-authored-by: Diptanil Saha fix(crm): using `get_list` instead of `get_all` in `get_opportunities` (#56463) --- erpnext/crm/doctype/prospect/prospect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py index 7ecbe637f04..02bd2a33be4 100644 --- a/erpnext/crm/doctype/prospect/prospect.py +++ b/erpnext/crm/doctype/prospect/prospect.py @@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None): @frappe.whitelist() def get_opportunities(prospect): - return frappe.get_all( + return frappe.get_list( "Opportunity", filters={"opportunity_from": "Prospect", "party_name": prospect}, fields=[ From 9adfab19dc8c67a682e6df79be867c9a4ca01ea0 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 25 Jun 2026 17:32:46 +0530 Subject: [PATCH 129/132] fix: handle missing serial and batch bundle in print format (cherry picked from commit 548d90df4f58cd0a7a885cdd3adfd85af2e81de7) --- erpnext/stock/serial_batch_bundle.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 1b030dbe2fa..d0cf769100f 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -588,10 +588,16 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None): def get_serial_or_batch_nos(bundle): # For print format + if not bundle: + return "" + bundle_data = frappe.get_cached_value( "Serial and Batch Bundle", bundle, ["has_serial_no", "has_batch_no"], as_dict=True ) + if not bundle_data: + return bundle + fields = [] if bundle_data.has_serial_no: fields.append("serial_no") From 3365373ec9e2c030cf72e76f77d7841993958721 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 26 Jun 2026 10:08:10 +0530 Subject: [PATCH 130/132] Revert "fix: handle missing serial and batch bundle in print format (backport #56497)" --- erpnext/stock/serial_batch_bundle.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index d0cf769100f..1b030dbe2fa 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -588,16 +588,10 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None): def get_serial_or_batch_nos(bundle): # For print format - if not bundle: - return "" - bundle_data = frappe.get_cached_value( "Serial and Batch Bundle", bundle, ["has_serial_no", "has_batch_no"], as_dict=True ) - if not bundle_data: - return bundle - fields = [] if bundle_data.has_serial_no: fields.append("serial_no") From 9e6edea818ad3a82fdf7cf66c562cfd6f32dfd3b Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Fri, 19 Jun 2026 10:51:36 +0530 Subject: [PATCH 131/132] fix: allow rename for Quality Inspection Parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quality Inspection Parameter DocType did not have `allow_rename` enabled, so the "Rename" action was hidden from the form's menu (the 3-dots / ⋮ options). Since the DocType is auto-named from the `parameter` field (`autoname: field:parameter`), users had no way to correct or change a parameter's name once created. Enable `allow_rename` so users can rename a Quality Inspection Parameter from the form menu. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit adfef48a65cbbe8019e97ac850d873f843d9f923) # Conflicts: # erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json --- .../quality_inspection_parameter.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json index 418b4825f2f..8915a580777 100644 --- a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_rename": 1, "autoname": "field:parameter", "creation": "2020-12-28 17:06:00.254129", "doctype": "DocType", @@ -34,7 +35,11 @@ ], "index_web_pages_for_search": 1, "links": [], +<<<<<<< HEAD "modified": "2021-02-19 20:33:30.657406", +======= + "modified": "2026-06-19 10:55:00.000000", +>>>>>>> adfef48a65 (fix: allow rename for Quality Inspection Parameter) "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection Parameter", From f2b7319462db6d6a137db0e16255fa1502ed538e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 26 Jun 2026 13:46:30 +0530 Subject: [PATCH 132/132] chore: resolve conflicts --- .../quality_inspection_parameter.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json index 8915a580777..f95c4a69482 100644 --- a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json @@ -35,11 +35,7 @@ ], "index_web_pages_for_search": 1, "links": [], -<<<<<<< HEAD - "modified": "2021-02-19 20:33:30.657406", -======= "modified": "2026-06-19 10:55:00.000000", ->>>>>>> adfef48a65 (fix: allow rename for Quality Inspection Parameter) "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection Parameter", @@ -98,4 +94,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +}