From 852c200ee053fc2ab2208c0866afe31808e07932 Mon Sep 17 00:00:00 2001 From: Markus Lobedann Date: Tue, 17 Feb 2026 12:46:30 +0100 Subject: [PATCH 01/53] fix: bug with comparison regarding `None` values and empty string In their default state, the fields can be `None`. When a user enters something and deletes it afterwards, the fields contain an empty string. This fixes the comparison. (cherry picked from commit 3fd5a0f1005f30219787f862786c31954935ac21) --- .../stock/doctype/quality_inspection/quality_inspection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index faef473a7fa..648836d0f6e 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -274,7 +274,9 @@ class QualityInspection(Document): def set_status_based_on_acceptance_values(self, reading): if not cint(reading.numeric): - result = reading.get("reading_value") == reading.get("value") + reading_value = reading.get("reading_value") or "" + value = reading.get("value") or "" + result = reading_value == value else: # numeric readings result = self.min_max_criteria_passed(reading) From c4325069126752b596e508b7f072bf5f6244a365 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 17 Feb 2026 21:07:30 +0530 Subject: [PATCH 02/53] fix: setup fails to set abbr to departments (cherry picked from commit debe868950fe7b433533e3ebdb129bef13e9c94e) --- erpnext/setup/doctype/department/department.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/department/department.py b/erpnext/setup/doctype/department/department.py index a698bd767b4..f1e80c17fb6 100644 --- a/erpnext/setup/doctype/department/department.py +++ b/erpnext/setup/doctype/department/department.py @@ -30,8 +30,7 @@ class Department(NestedSet): nsm_parent_field = "parent_department" def autoname(self): - root = get_root_of("Department") - if root and self.department_name != root: + if self.company: self.name = get_abbreviated_name(self.department_name, self.company) else: self.name = self.department_name From 460291990aa570acf7aa2b113cbe68ddb929a6bc Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 4 Feb 2026 16:54:51 +0530 Subject: [PATCH 03/53] fix: enfore permission on make_payment_request (cherry picked from commit b755ca12ca2afd3e5587b81fd280820a298fbec5) --- erpnext/accounts/doctype/payment_request/payment_request.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 752085e5f99..481c5f79ca5 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -536,10 +536,12 @@ class PaymentRequest(Document): row_number += TO_SKIP_NEW_ROW -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def make_payment_request(**args): """Make payment request""" + frappe.has_permission(doctype="Payment Request", ptype="write", throw=True) + args = frappe._dict(args) if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST: From ce7101f5556eecfab982d40aa2a54fa80d8f6da8 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 16 Feb 2026 13:19:10 +0530 Subject: [PATCH 04/53] fix: better permissions on make payment request (cherry picked from commit f36962fc5842361872caccc13ec56567a5c1e203) --- .../accounts/doctype/payment_request/payment_request.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 481c5f79ca5..8ff021660cc 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -540,8 +540,6 @@ class PaymentRequest(Document): def make_payment_request(**args): """Make payment request""" - frappe.has_permission(doctype="Payment Request", ptype="write", throw=True) - args = frappe._dict(args) if args.dt not in ALLOWED_DOCTYPES_FOR_PAYMENT_REQUEST: @@ -550,6 +548,9 @@ def make_payment_request(**args): if args.dn and not isinstance(args.dn, str): frappe.throw(_("Invalid parameter. 'dn' should be of type str")) + frappe.has_permission("Payment Request", "create", throw=True) + frappe.has_permission(args.dt, "read", args.dn, throw=True) + ref_doc = args.ref_doc or frappe.get_doc(args.dt, args.dn) if not args.get("company"): args.company = ref_doc.company @@ -824,7 +825,7 @@ def get_print_format_list(ref_doctype): return {"print_format": print_format_list} -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def resend_payment_email(docname): return frappe.get_doc("Payment Request", docname).send_email() From d782c52b76f49ce7f7cf81519ba7b35f7aa1c8e5 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:52:50 +0530 Subject: [PATCH 05/53] Revert "fix(profit and loss statement): exclude non period columns (backport #52279)" --- .../profit_and_loss_statement/profit_and_loss_statement.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py index 00ab15dba12..ccb4d26f77b 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.py @@ -159,11 +159,11 @@ def get_net_profit_loss(income, expense, period_list, company, currency=None, co def get_chart_data(filters, columns, income, expense, net_profit_loss, currency): - labels = [d.get("label") for d in columns[4:]] + labels = [d.get("label") for d in columns[2:]] income_data, expense_data, net_profit = [], [], [] - for p in columns[4:]: + for p in columns[2:]: if income: income_data.append(income[-2].get(p.get("fieldname"))) if expense: From 33d48c55750324ff1da83414955d8e2f52361b3f Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 12 Feb 2026 00:35:27 +0530 Subject: [PATCH 06/53] fix(manufacturing): set pick list purpose while creating it from work order (cherry picked from commit 23ccc2a8c5318e2faeed48413b5a5418c060ffc3) --- erpnext/manufacturing/doctype/work_order/work_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index f5a5e2693b9..a1347a8d901 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1765,6 +1765,7 @@ def create_pick_list(source_name, target_doc=None, for_qty=None): target_doc, ) + doc.purpose = "Material Transfer for Manufacture" doc.for_qty = for_qty doc.set_item_locations() From d9f1b0be77e3e1ec61d38ef788b11f5478a36d58 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:08:56 +0000 Subject: [PATCH 07/53] Merge pull request #52785 from frappe/mergify/bp/version-15-hotfix/pr-52712 fix: addresses portal (backport #52712) --- erpnext/hooks.py | 2 +- erpnext/patches.txt | 1 + erpnext/patches/v16_0/add_portal_redirects.py | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 erpnext/patches/v16_0/add_portal_redirects.py diff --git a/erpnext/hooks.py b/erpnext/hooks.py index aaea9e712d4..f3ccc5783a7 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -256,7 +256,7 @@ standard_portal_menu_items = [ "role": "Customer", }, {"title": "Issues", "route": "/issues", "reference_doctype": "Issue", "role": "Customer"}, - {"title": "Addresses", "route": "/addresses", "reference_doctype": "Address"}, + {"title": "Addresses", "route": "/addresses", "reference_doctype": "Address", "role": "Customer"}, { "title": "Timesheets", "route": "/timesheets", diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5c7102b1ad7..eb80eb59354 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -430,3 +430,4 @@ erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter #2025-12 erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges erpnext.patches.v16_0.set_ordered_qty_in_quotation_item erpnext.patches.v15_0.replace_http_with_https_in_sales_partner +erpnext.patches.v16_0.add_portal_redirects diff --git a/erpnext/patches/v16_0/add_portal_redirects.py b/erpnext/patches/v16_0/add_portal_redirects.py new file mode 100644 index 00000000000..3a3e553d2ea --- /dev/null +++ b/erpnext/patches/v16_0/add_portal_redirects.py @@ -0,0 +1,14 @@ +import frappe + + +def execute(): + if frappe.db.exists("Portal Menu Item", {"route": "/addresses", "reference_doctype": "Address"}) and ( + doc := frappe.get_doc("Portal Menu Item", {"route": "/addresses", "reference_doctype": "Address"}) + ): + doc.role = "Customer" + doc.save() + + website_settings = frappe.get_single("Website Settings") + website_settings.append("route_redirects", {"source": "addresses", "target": "address/list"}) + website_settings.append("route_redirects", {"source": "projects", "target": "project"}) + website_settings.save() From 41c7890a6d0ca32410e24a3a6739a13f51e409d7 Mon Sep 17 00:00:00 2001 From: Thomas antony <77287334+thomasantony12@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:48:15 +0530 Subject: [PATCH 08/53] fix: Add handling for Sales Invoice Item quantity field Add handling for Sales Invoice Item quantity field (cherry picked from commit edfcaee99b19906191e2c1c7db44ce11966f1833) --- .../doctype/serial_and_batch_bundle/serial_and_batch_bundle.py | 2 ++ 1 file changed, 2 insertions(+) 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 69a82ab64cb..1c9dff446f5 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 @@ -1032,6 +1032,8 @@ class SerialandBatchBundle(Document): qty_field = "consumed_qty" elif row.get("doctype") == "Stock Entry Detail": qty_field = "transfer_qty" + elif row.get("doctype") == "Sales Invoice Item": + qty_field = "stock_qty" return qty_field From 2fc3e30f9fba5e10425c9487469d41db65bbf931 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 04:45:20 +0530 Subject: [PATCH 09/53] fix: add purchase invoice as well (cherry picked from commit 1fc2eddf6f6d1630bbef04a36277e38f68dd4592) --- .../doctype/serial_and_batch_bundle/serial_and_batch_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c9dff446f5..24638559116 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 @@ -1032,7 +1032,7 @@ class SerialandBatchBundle(Document): qty_field = "consumed_qty" elif row.get("doctype") == "Stock Entry Detail": qty_field = "transfer_qty" - elif row.get("doctype") == "Sales Invoice Item": + elif row.get("doctype") in ["Sales Invoice Item", "Purchase Invoice Item"]: qty_field = "stock_qty" return qty_field From c3626d67ca7ad6c0649ab2415aa7f9379099c58a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 10:18:26 +0530 Subject: [PATCH 10/53] fix: reservation based on field should be read only in SRE (cherry picked from commit 21452b4c6efa8659801d888b5b1445750789a8c1) # Conflicts: # erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json --- .../stock_reservation_entry/stock_reservation_entry.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index bf5ea741e3f..8832ec1e2e4 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -261,7 +261,6 @@ "label": "Serial and Batch Reservation" }, { - "allow_on_submit": 1, "default": "Qty", "depends_on": "eval: parent.has_serial_no || parent.has_batch_no", "fieldname": "reservation_based_on", @@ -269,7 +268,7 @@ "label": "Reservation Based On", "no_copy": 1, "options": "Qty\nSerial and Batch", - "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.from_voucher_type == \"Pick List\")" + "read_only": 1 }, { "fieldname": "column_break_7dxj", @@ -315,11 +314,15 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], +<<<<<<< HEAD "modified": "2024-02-07 16:05:17.772098", +======= + "modified": "2026-02-19 10:17:28.695394", +>>>>>>> 21452b4c6e (fix: reservation based on field should be read only in SRE) "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { From 6d32089d9e8a27865858de3f78cb0743c8bf92d8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 10:51:40 +0530 Subject: [PATCH 11/53] chore: resolve conflicts --- .../stock_reservation_entry/stock_reservation_entry.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 8832ec1e2e4..41bfbd85e22 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -314,11 +314,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], -<<<<<<< HEAD - "modified": "2024-02-07 16:05:17.772098", -======= "modified": "2026-02-19 10:17:28.695394", ->>>>>>> 21452b4c6e (fix: reservation based on field should be read only in SRE) "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -428,4 +424,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 042211700387ff4e2f352e632ae25ad907579e7c Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 10:13:14 +0530 Subject: [PATCH 12/53] fix: unable to submit subcontracting order if created from material request (cherry picked from commit 37323480dd026fd5745b75f48d38ad57d692b32e) --- .../doctype/purchase_order/purchase_order.py | 3 +++ erpnext/controllers/status_updater.py | 2 +- .../material_request/material_request_list.js | 4 ++-- .../subcontracting_order.py | 19 ------------------- 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 751254babf1..5d1513df9b2 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -196,6 +196,9 @@ class PurchaseOrder(BuyingController): self.set_has_unit_price_items() self.flags.allow_zero_qty = self.has_unit_price_items + if self.is_subcontracted: + self.status_updater[0]["source_field"] = "fg_item_qty" + def validate(self): super().validate() diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index dcecd995a48..290d8eb5d4b 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -111,7 +111,7 @@ status_map = { ["Pending", "eval:self.status != 'Stopped' and self.per_ordered == 0 and self.docstatus == 1"], [ "Ordered", - "eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type in ['Purchase', 'Manufacture']", + "eval:self.status != 'Stopped' and self.per_ordered == 100 and self.docstatus == 1 and self.material_request_type in ['Purchase', 'Manufacture', 'Subcontracting']", ], [ "Transferred", diff --git a/erpnext/stock/doctype/material_request/material_request_list.js b/erpnext/stock/doctype/material_request/material_request_list.js index 69d8c15803d..113ab61f8c0 100644 --- a/erpnext/stock/doctype/material_request/material_request_list.js +++ b/erpnext/stock/doctype/material_request/material_request_list.js @@ -25,7 +25,7 @@ frappe.listview_settings["Material Request"] = { ) { return [__("Partially Received"), "yellow", "per_ordered,<,100"]; } else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) < 100) { - return [__("Partially ordered"), "yellow", "per_ordered,<,100"]; + return [__("Partially Ordered"), "yellow", "per_ordered,<,100"]; } else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) == 100) { if ( doc.material_request_type == "Purchase" && @@ -35,7 +35,7 @@ frappe.listview_settings["Material Request"] = { return [__("Partially Received"), "yellow", "per_received,<,100"]; } else if (doc.material_request_type == "Purchase" && flt(doc.per_received, precision) == 100) { return [__("Received"), "green", "per_received,=,100"]; - } else if (["Purchase", "Manufacture"].includes(doc.material_request_type)) { + } else if (["Purchase", "Manufacture", "Subcontracting"].includes(doc.material_request_type)) { return [__("Ordered"), "green", "per_ordered,=,100"]; } else if (doc.material_request_type == "Material Transfer") { return [__("Transfered"), "green", "per_ordered,=,100"]; diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index f764329e338..aaf0ec87c23 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -81,23 +81,6 @@ class SubcontractingOrder(SubcontractingController): transaction_date: DF.Date # end: auto-generated types - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.status_updater = [ - { - "source_dt": "Subcontracting Order Item", - "target_dt": "Material Request Item", - "join_field": "material_request_item", - "target_field": "ordered_qty", - "target_parent_dt": "Material Request", - "target_parent_field": "per_ordered", - "target_ref_field": "stock_qty", - "source_field": "qty", - "percent_join_field": "material_request", - } - ] - def onload(self): self.set_onload( "over_transfer_allowance", @@ -117,12 +100,10 @@ class SubcontractingOrder(SubcontractingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") def on_submit(self): - self.update_prevdoc_status() self.update_status() self.update_subcontracted_quantity_in_po() def on_cancel(self): - self.update_prevdoc_status() self.update_status() self.update_subcontracted_quantity_in_po(cancel=True) From fd48fb49b9d03c83c3c481a1a073dde63265869f Mon Sep 17 00:00:00 2001 From: Marc Ramser Date: Thu, 19 Feb 2026 09:25:38 +0100 Subject: [PATCH 13/53] fix(Purchase Receipt): copy project from first row when adding items Adds `items_add` method to copy expense_account, cost_center and project from first row to newly added items, matching Purchase Invoice behavior. (cherry picked from commit 21423676c9e615317c71ea74e3f1df47b1d40752) --- .../stock/doctype/purchase_receipt/purchase_receipt.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index b5c1c38729f..60981047d9e 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -365,6 +365,15 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend apply_putaway_rule() { if (this.frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(this.frm); } + + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + this.frm.script_manager.copy_from_first_row("items", row, [ + "expense_account", + "cost_center", + "project", + ]); + } }; // for backward compatibility: combine new and previous states From e2a1a7a36d8c057f09b029c180135d37237a42b8 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:41:01 +0000 Subject: [PATCH 14/53] feat: update item button addition for quotation (backport #50976) (#52810) * feat: update item button addition for quotation (#50976) * feat: update item button addition for quotation * feat: update item button addition for supplier quotation * fix: test case --------- Co-authored-by: Nishka Gosalia Co-authored-by: Mihir Kandoi (cherry picked from commit f4c0611cc55c069244a2d8f9b42ffa8297e1fa08) # Conflicts: # erpnext/buying/doctype/supplier_quotation/supplier_quotation.js # erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py # erpnext/controllers/accounts_controller.py # erpnext/selling/doctype/quotation/test_quotation.py * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- .../supplier_quotation/supplier_quotation.js | 8 + .../supplier_quotation/supplier_quotation.py | 12 ++ .../test_supplier_quotation.py | 102 +++++++++++- erpnext/controllers/accounts_controller.py | 148 +++++++++++------- .../selling/doctype/quotation/quotation.js | 7 + .../selling/doctype/quotation/quotation.py | 1 + .../doctype/quotation/test_quotation.py | 99 +++++++++++- 7 files changed, 322 insertions(+), 55 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index fccca81f8ce..e81f9f9c988 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -30,6 +30,14 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.add_custom_button(__("Purchase Order"), this.make_purchase_order, __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); + + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } else if (this.frm.doc.docstatus === 0) { erpnext.set_unit_price_items_note(this.frm); diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 35cb2eebf8f..790e89f8c0e 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -345,3 +345,15 @@ def set_expired_status(): """, (nowdate()), ) + + +def get_purchased_items(supplier_quotation: str): + return frappe._dict( + frappe.get_all( + "Purchase Order Item", + filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, + fields=["supplier_quotation_item", "sum(qty)"], + group_by="supplier_quotation_item", + as_list=1, + ) + ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 60c82bbc05f..da4c78347f3 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -2,15 +2,115 @@ # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate class TestPurchaseOrder(FrappeTestCase): + def test_update_child_supplier_quotation_add_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": 5, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(sq.get("items")[0].qty, 5) + self.assertEqual(sq.get("items")[1].rate, 300) + + def test_update_supplier_quotation_child_rate_disallow(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 300, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) + self.assertRaises( + frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + + def test_update_supplier_quotation_child_remove_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + po = make_purchase_order(sq.name) + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + po.get("items")[0].schedule_date = add_days(today(), 1) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + po.submit() + sq.reload() + + trans_item = json.dumps( + [ + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + } + ] + ) + + frappe.db.savepoint("before_cancel") + # check if item having purchase order can be removed + self.assertRaises( + frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + frappe.db.rollback(save_point="before_cancel") + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + } + ] + ) + + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(len(sq.get("items")), 1) + def test_supplier_quotation_qty(self): sq = frappe.copy_doc(test_records[0]) sq.items[0].qty = 0 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 66a3dd93169..a88f7d02fb0 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3678,7 +3678,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - if child_doctype == "Purchase Order Item": + if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 @@ -3696,7 +3696,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child return child_item -def validate_child_on_delete(row, parent): +def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": if flt(row.delivered_qty): @@ -3724,13 +3724,17 @@ def validate_child_on_delete(row, parent): row.idx, row.item_code ) ) - - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code + if parent.doctype in ["Purchase Order", "Sales Order"]: + if flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) ) - ) + + if parent.doctype == "Quotation": + if ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) def update_bin_on_delete(row, doctype): @@ -3756,7 +3760,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data) -> bool: +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -3764,7 +3768,7 @@ def validate_and_delete_children(parent, data) -> bool: deleted_children.append(item) for d in deleted_children: - validate_child_on_delete(d, parent) + validate_child_on_delete(d, parent, ordered_item) d.cancel() d.delete() @@ -3773,16 +3777,19 @@ def validate_and_delete_children(parent, data) -> bool: # need to update ordered qty in Material Request first # bin uses Material Request Items to recalculate & update - parent.update_prevdoc_status() - - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) + if parent.doctype not in ["Quotation", "Supplier Quotation"]: + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) return bool(deleted_children) @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.quotation import get_ordered_items + def check_doc_permissions(doc, perm_type="create"): try: doc.check_permission(perm_type) @@ -3821,7 +3828,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + child_doctype = parent_doctype + " Item" return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def is_allowed_zero_qty(): @@ -3846,6 +3853,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): frappe.throw(_("Cannot set quantity less than received quantity")) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if (parent_doctype == "Quotation" and not ordered_items) or ( + parent_doctype == "Supplier Quotation" and not purchased_items + ): + return + + qty_to_check = ( + ordered_items.get(child_item.name) + if parent_doctype == "Quotation" + else purchased_items.get(child_item.name) + ) + if qty_to_check: + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + def should_update_supplied_items(doc) -> bool: """Subcontracted PO can allow following changes *after submit*: @@ -3888,7 +3910,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) data = json.loads(trans_items) - any_qty_changed = False # updated to true if any item's qty changes items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False @@ -3896,7 +3917,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - _removed_items = validate_and_delete_children(parent, data) + + if parent_doctype == "Quotation": + ordered_items = get_ordered_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, ordered_items) + elif parent_doctype == "Supplier Quotation": + purchased_items = get_purchased_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, purchased_items) + else: + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: @@ -3936,7 +3966,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( - prev_date == getdate(new_date) if prev_date and new_date else False + (prev_date == getdate(new_date) if prev_date and new_date else False) + if parent_doctype not in ["Quotation", "Supplier Quotation"] + else None ) # in case of delivery note etc if ( rate_unchanged @@ -3949,6 +3981,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if not rate_unchanged: + frappe.throw(_("Rates cannot be modified for quoted items")) + if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True @@ -3972,18 +4008,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - # Amount cannot be lesser than billed amount, except for negative amounts row_rate = flt(d.get("rate"), rate_precision) - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(d.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) + + if parent_doctype in ["Purchase Order", "Sales Order"]: + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + else: + child_item.rate = row_rate else: child_item.rate = row_rate @@ -4017,26 +4056,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if d.get("bom_no") and parent_doctype == "Sales Order": child_item.bom_no = d.get("bom_no") - if flt(child_item.price_list_rate): - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin - # or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + if parent_doctype in ["Sales Order", "Purchase Order"]: + if flt(child_item.price_list_rate): + if flt(child_item.rate) > flt(child_item.price_list_rate): + # if rate is greater than price_list_rate, set margin + # or set discount + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -4058,14 +4098,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.doctype, parent.company, parent.base_grand_total ) - parent.set_payment_schedule() + if parent_doctype != "Supplier Quotation": + parent.set_payment_schedule() if parent_doctype == "Purchase Order": parent.set_tax_withholding() parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): parent.update_status_updater() - else: + elif parent_doctype == "Sales Order": parent.check_credit_limit() # reset index of child table @@ -4098,7 +4139,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." ).format(frappe.bold(parent.name)) ) - else: # Sales Order + else: parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_warehouse() @@ -4110,9 +4151,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.reload() validate_workflow_conditions(parent) - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() + if parent_doctype in ["Purchase Order", "Sales Order"]: + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("stock_uom", "stock_qty") diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index f0061c016bd..480ca04b6a9 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } if (doc.status !== "Ordered" && this.frm.has_perm("write")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index f41116203ba..7a31854d259 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -614,6 +614,7 @@ def handle_mandatory_error(e, customer, lead_name): frappe.throw(message, title=_("Mandatory Missing")) +@frappe.whitelist() def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index ae756f34288..b11f23806cb 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1,17 +1,114 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate +from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.setup.utils import get_exchange_rate test_dependencies = ["Product Bundle"] class TestQuotation(FrappeTestCase): + def test_update_child_quotation_add_item(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_1 = make_item("_Test Item") + item_2 = make_item("_Test Item 1") + + item_list = [ + {"item_code": item_1.item_code, "warehouse": "", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + first_item = qo.get("items")[0] + second_item = qo.get("items")[1] + trans_item = json.dumps( + [ + { + "item_code": first_item.item_code, + "rate": first_item.rate, + "qty": 11, + "docname": first_item.name, + }, + { + "item_code": second_item.item_code, + "rate": second_item.rate, + "qty": second_item.qty, + "docname": second_item.name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(qo.get("items")[0].qty, 11) + self.assertEqual(qo.get("items")[-1].rate, 100) + + def test_update_child_disallow_rate_change(self): + qo = make_quotation(qty=4) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": 5000, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + def test_update_child_removing_item(self): + qo = make_quotation(qty=10) + sales_order = make_sales_order(qo.name) + sales_order.delivery_date = nowdate() + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + sales_order.submit() + qo.reload() + self.assertEqual(qo.status, "Partially Ordered") + + trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}]) + + # check if items having a sales order can be removed + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + + # remove item with no sales order + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(len(qo.get("items")), 1) + def test_quotation_qty(self): qo = make_quotation(qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): From 656b1bcede274002e54eb2ff034845bc1787d160 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 20:22:25 +0530 Subject: [PATCH 15/53] =?UTF-8?q?Revert=20"feat:=20update=20item=20button?= =?UTF-8?q?=20addition=20for=20quotation=20(backport=20#50976)=20(#5?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit e2a1a7a36d8c057f09b029c180135d37237a42b8. --- .../supplier_quotation/supplier_quotation.js | 8 - .../supplier_quotation/supplier_quotation.py | 12 -- .../test_supplier_quotation.py | 102 +----------- erpnext/controllers/accounts_controller.py | 148 +++++++----------- .../selling/doctype/quotation/quotation.js | 7 - .../selling/doctype/quotation/quotation.py | 1 - .../doctype/quotation/test_quotation.py | 99 +----------- 7 files changed, 55 insertions(+), 322 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index e81f9f9c988..fccca81f8ce 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -30,14 +30,6 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.add_custom_button(__("Purchase Order"), this.make_purchase_order, __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); - - this.frm.add_custom_button(__("Update Items"), () => { - erpnext.utils.update_child_items({ - frm: this.frm, - child_docname: "items", - cannot_add_row: false, - }); - }); } else if (this.frm.doc.docstatus === 0) { erpnext.set_unit_price_items_note(this.frm); diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 790e89f8c0e..35cb2eebf8f 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -345,15 +345,3 @@ def set_expired_status(): """, (nowdate()), ) - - -def get_purchased_items(supplier_quotation: str): - return frappe._dict( - frappe.get_all( - "Purchase Order Item", - filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, - fields=["supplier_quotation_item", "sum(qty)"], - group_by="supplier_quotation_item", - as_list=1, - ) - ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index da4c78347f3..60c82bbc05f 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -2,115 +2,15 @@ # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order -from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate +from erpnext.controllers.accounts_controller import InvalidQtyError class TestPurchaseOrder(FrappeTestCase): - def test_update_child_supplier_quotation_add_item(self): - sq = frappe.copy_doc(test_records[0]) - sq.submit() - - trans_item = json.dumps( - [ - { - "item_code": sq.items[0].item_code, - "rate": sq.items[0].rate, - "qty": 5, - "docname": sq.items[0].name, - }, - { - "item_code": "_Test Item 2", - "rate": 300, - "qty": 3, - }, - ] - ) - update_child_qty_rate("Supplier Quotation", trans_item, sq.name) - sq.reload() - self.assertEqual(sq.get("items")[0].qty, 5) - self.assertEqual(sq.get("items")[1].rate, 300) - - def test_update_supplier_quotation_child_rate_disallow(self): - sq = frappe.copy_doc(test_records[0]) - sq.submit() - trans_item = json.dumps( - [ - { - "item_code": sq.items[0].item_code, - "rate": 300, - "qty": sq.items[0].qty, - "docname": sq.items[0].name, - }, - ] - ) - self.assertRaises( - frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name - ) - - def test_update_supplier_quotation_child_remove_item(self): - sq = frappe.copy_doc(test_records[0]) - sq.submit() - po = make_purchase_order(sq.name) - - trans_item = json.dumps( - [ - { - "item_code": sq.items[0].item_code, - "rate": sq.items[0].rate, - "qty": sq.items[0].qty, - "docname": sq.items[0].name, - }, - { - "item_code": "_Test Item 2", - "rate": 300, - "qty": 3, - }, - ] - ) - po.get("items")[0].schedule_date = add_days(today(), 1) - update_child_qty_rate("Supplier Quotation", trans_item, sq.name) - po.submit() - sq.reload() - - trans_item = json.dumps( - [ - { - "item_code": "_Test Item 2", - "rate": 300, - "qty": 3, - } - ] - ) - - frappe.db.savepoint("before_cancel") - # check if item having purchase order can be removed - self.assertRaises( - frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name - ) - frappe.db.rollback(save_point="before_cancel") - - trans_item = json.dumps( - [ - { - "item_code": sq.items[0].item_code, - "rate": sq.items[0].rate, - "qty": sq.items[0].qty, - "docname": sq.items[0].name, - } - ] - ) - - update_child_qty_rate("Supplier Quotation", trans_item, sq.name) - sq.reload() - self.assertEqual(len(sq.get("items")), 1) - def test_supplier_quotation_qty(self): sq = frappe.copy_doc(test_records[0]) sq.items[0].qty = 0 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index a88f7d02fb0..66a3dd93169 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3678,7 +3678,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: + if child_doctype == "Purchase Order Item": # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 @@ -3696,7 +3696,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child return child_item -def validate_child_on_delete(row, parent, ordered_item=None): +def validate_child_on_delete(row, parent): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": if flt(row.delivered_qty): @@ -3724,17 +3724,13 @@ def validate_child_on_delete(row, parent, ordered_item=None): row.idx, row.item_code ) ) - if parent.doctype in ["Purchase Order", "Sales Order"]: - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code - ) - ) - if parent.doctype == "Quotation": - if ordered_item.get(row.name): - frappe.throw(_("Cannot delete an item which has been ordered")) + if flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) + ) def update_bin_on_delete(row, doctype): @@ -3760,7 +3756,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data, ordered_item=None) -> bool: +def validate_and_delete_children(parent, data) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -3768,7 +3764,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool: deleted_children.append(item) for d in deleted_children: - validate_child_on_delete(d, parent, ordered_item) + validate_child_on_delete(d, parent) d.cancel() d.delete() @@ -3777,19 +3773,16 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool: # need to update ordered qty in Material Request first # bin uses Material Request Items to recalculate & update - if parent.doctype not in ["Quotation", "Supplier Quotation"]: - parent.update_prevdoc_status() - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) + parent.update_prevdoc_status() + + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) return bool(deleted_children) @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items - from erpnext.selling.doctype.quotation.quotation import get_ordered_items - def check_doc_permissions(doc, perm_type="create"): try: doc.check_permission(perm_type) @@ -3828,7 +3821,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = parent_doctype + " Item" + child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def is_allowed_zero_qty(): @@ -3853,21 +3846,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): frappe.throw(_("Cannot set quantity less than received quantity")) - if parent_doctype in ["Quotation", "Supplier Quotation"]: - if (parent_doctype == "Quotation" and not ordered_items) or ( - parent_doctype == "Supplier Quotation" and not purchased_items - ): - return - - qty_to_check = ( - ordered_items.get(child_item.name) - if parent_doctype == "Quotation" - else purchased_items.get(child_item.name) - ) - if qty_to_check: - if flt(new_data.get("qty")) < qty_to_check: - frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) - def should_update_supplied_items(doc) -> bool: """Subcontracted PO can allow following changes *after submit*: @@ -3910,6 +3888,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) data = json.loads(trans_items) + any_qty_changed = False # updated to true if any item's qty changes items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False @@ -3917,16 +3896,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - - if parent_doctype == "Quotation": - ordered_items = get_ordered_items(parent.name) - _removed_items = validate_and_delete_children(parent, data, ordered_items) - elif parent_doctype == "Supplier Quotation": - purchased_items = get_purchased_items(parent.name) - _removed_items = validate_and_delete_children(parent, data, purchased_items) - else: - _removed_items = validate_and_delete_children(parent, data) - + _removed_items = validate_and_delete_children(parent, data) items_added_or_removed |= _removed_items for d in data: @@ -3966,9 +3936,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( - (prev_date == getdate(new_date) if prev_date and new_date else False) - if parent_doctype not in ["Quotation", "Supplier Quotation"] - else None + prev_date == getdate(new_date) if prev_date and new_date else False ) # in case of delivery note etc if ( rate_unchanged @@ -3981,10 +3949,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) - if parent_doctype in ["Quotation", "Supplier Quotation"]: - if not rate_unchanged: - frappe.throw(_("Rates cannot be modified for quoted items")) - if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True @@ -4008,21 +3972,18 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) + # Amount cannot be lesser than billed amount, except for negative amounts row_rate = flt(d.get("rate"), rate_precision) - - if parent_doctype in ["Purchase Order", "Sales Order"]: - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(d.get("qty"), qty_precision), rate_precision + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision + ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) - ) - else: - child_item.rate = row_rate else: child_item.rate = row_rate @@ -4056,27 +4017,26 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if d.get("bom_no") and parent_doctype == "Sales Order": child_item.bom_no = d.get("bom_no") - if parent_doctype in ["Sales Order", "Purchase Order"]: - if flt(child_item.price_list_rate): - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin - # or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + if flt(child_item.price_list_rate): + if flt(child_item.rate) > flt(child_item.price_list_rate): + # if rate is greater than price_list_rate, set margin + # or set discount + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -4098,15 +4058,14 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.doctype, parent.company, parent.base_grand_total ) - if parent_doctype != "Supplier Quotation": - parent.set_payment_schedule() + parent.set_payment_schedule() if parent_doctype == "Purchase Order": parent.set_tax_withholding() parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): parent.update_status_updater() - elif parent_doctype == "Sales Order": + else: parent.check_credit_limit() # reset index of child table @@ -4139,7 +4098,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." ).format(frappe.bold(parent.name)) ) - else: + else: # Sales Order parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_warehouse() @@ -4151,10 +4110,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.reload() validate_workflow_conditions(parent) - if parent_doctype in ["Purchase Order", "Sales Order"]: - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("stock_uom", "stock_qty") diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 480ca04b6a9..f0061c016bd 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,13 +123,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); - this.frm.add_custom_button(__("Update Items"), () => { - erpnext.utils.update_child_items({ - frm: this.frm, - child_docname: "items", - cannot_add_row: false, - }); - }); } if (doc.status !== "Ordered" && this.frm.has_perm("write")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 7a31854d259..f41116203ba 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -614,7 +614,6 @@ def handle_mandatory_error(e, customer, lead_name): frappe.throw(message, title=_("Mandatory Missing")) -@frappe.whitelist() def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index b11f23806cb..ae756f34288 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1,114 +1,17 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -import json - import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate -from erpnext.selling.doctype.quotation.quotation import make_sales_order +from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.setup.utils import get_exchange_rate test_dependencies = ["Product Bundle"] class TestQuotation(FrappeTestCase): - def test_update_child_quotation_add_item(self): - from erpnext.stock.doctype.item.test_item import make_item - - item_1 = make_item("_Test Item") - item_2 = make_item("_Test Item 1") - - item_list = [ - {"item_code": item_1.item_code, "warehouse": "", "qty": 10, "rate": 300}, - {"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400}, - ] - - qo = make_quotation(item_list=item_list) - first_item = qo.get("items")[0] - second_item = qo.get("items")[1] - trans_item = json.dumps( - [ - { - "item_code": first_item.item_code, - "rate": first_item.rate, - "qty": 11, - "docname": first_item.name, - }, - { - "item_code": second_item.item_code, - "rate": second_item.rate, - "qty": second_item.qty, - "docname": second_item.name, - }, - {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, - ] - ) - - update_child_qty_rate("Quotation", trans_item, qo.name) - qo.reload() - self.assertEqual(qo.get("items")[0].qty, 11) - self.assertEqual(qo.get("items")[-1].rate, 100) - - def test_update_child_disallow_rate_change(self): - qo = make_quotation(qty=4) - trans_item = json.dumps( - [ - { - "item_code": qo.items[0].item_code, - "rate": 5000, - "qty": qo.items[0].qty, - "docname": qo.items[0].name, - } - ] - ) - self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) - - def test_update_child_removing_item(self): - qo = make_quotation(qty=10) - sales_order = make_sales_order(qo.name) - sales_order.delivery_date = nowdate() - - trans_item = json.dumps( - [ - { - "item_code": qo.items[0].item_code, - "rate": qo.items[0].rate, - "qty": qo.items[0].qty, - "docname": qo.items[0].name, - }, - {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, - ] - ) - - update_child_qty_rate("Quotation", trans_item, qo.name) - sales_order.submit() - qo.reload() - self.assertEqual(qo.status, "Partially Ordered") - - trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}]) - - # check if items having a sales order can be removed - self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) - - trans_item = json.dumps( - [ - { - "item_code": qo.items[0].item_code, - "rate": qo.items[0].rate, - "qty": qo.items[0].qty, - "docname": qo.items[0].name, - } - ] - ) - - # remove item with no sales order - update_child_qty_rate("Quotation", trans_item, qo.name) - qo.reload() - self.assertEqual(len(qo.get("items")), 1) - def test_quotation_qty(self): qo = make_quotation(qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): From 5a3c02743238a975bbd978d2a60f6b60cba0fd35 Mon Sep 17 00:00:00 2001 From: Nishka Gosalia Date: Thu, 19 Feb 2026 17:16:06 +0530 Subject: [PATCH 16/53] fix: permission issue for quotation item during update item (cherry picked from commit 58b8af0fa8424a82a20a7009686245ea663dacd1) --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 66a3dd93169..6ea48c431b3 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -4044,7 +4044,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil child_item.idx = len(parent.items) + 1 child_item.insert() else: - child_item.save() + parent.save() parent.reload() parent.flags.ignore_validate_update_after_submit = True From bce77b6117f418f3cae08bbcdc22f977694de8e8 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 20:27:54 +0530 Subject: [PATCH 17/53] fix: ignore permissions instead of saving parent (cherry picked from commit 6342e9a3e297269880d2dfd8a98767d5898504ee) --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 6ea48c431b3..3c5208fa2c3 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -4044,7 +4044,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil child_item.idx = len(parent.items) + 1 child_item.insert() else: - parent.save() + child.save(ignore_permissions=True) parent.reload() parent.flags.ignore_validate_update_after_submit = True From 38939005caa265b407939fb54f484d07e399a986 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 19 Feb 2026 20:28:33 +0530 Subject: [PATCH 18/53] fix: typo (cherry picked from commit 732c98b72f3bb4cedd55a76b645c4de5609a3b10) --- erpnext/controllers/accounts_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 3c5208fa2c3..9795f39622b 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -4044,7 +4044,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil child_item.idx = len(parent.items) + 1 child_item.insert() else: - child.save(ignore_permissions=True) + child_item.save(ignore_permissions=True) parent.reload() parent.flags.ignore_validate_update_after_submit = True From 800e38453b662a18e5473ad058c291fb347722b0 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:41:01 +0000 Subject: [PATCH 19/53] feat: update item button addition for quotation (backport #50976) (#52810) * feat: update item button addition for quotation (#50976) * feat: update item button addition for quotation * feat: update item button addition for supplier quotation * fix: test case --------- Co-authored-by: Nishka Gosalia Co-authored-by: Mihir Kandoi (cherry picked from commit f4c0611cc55c069244a2d8f9b42ffa8297e1fa08) * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- .../supplier_quotation/supplier_quotation.js | 8 + .../supplier_quotation/supplier_quotation.py | 12 ++ .../test_supplier_quotation.py | 102 +++++++++++- erpnext/controllers/accounts_controller.py | 148 +++++++++++------- .../selling/doctype/quotation/quotation.js | 7 + .../selling/doctype/quotation/quotation.py | 1 + .../doctype/quotation/test_quotation.py | 99 +++++++++++- 7 files changed, 322 insertions(+), 55 deletions(-) diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index fccca81f8ce..e81f9f9c988 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -30,6 +30,14 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.add_custom_button(__("Purchase Order"), this.make_purchase_order, __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); + + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } else if (this.frm.doc.docstatus === 0) { erpnext.set_unit_price_items_note(this.frm); diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 35cb2eebf8f..790e89f8c0e 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -345,3 +345,15 @@ def set_expired_status(): """, (nowdate()), ) + + +def get_purchased_items(supplier_quotation: str): + return frappe._dict( + frappe.get_all( + "Purchase Order Item", + filters={"supplier_quotation": supplier_quotation, "docstatus": 1}, + fields=["supplier_quotation_item", "sum(qty)"], + group_by="supplier_quotation_item", + as_list=1, + ) + ) diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 60c82bbc05f..da4c78347f3 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -2,15 +2,115 @@ # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate class TestPurchaseOrder(FrappeTestCase): + def test_update_child_supplier_quotation_add_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": 5, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(sq.get("items")[0].qty, 5) + self.assertEqual(sq.get("items")[1].rate, 300) + + def test_update_supplier_quotation_child_rate_disallow(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 300, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) + self.assertRaises( + frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + + def test_update_supplier_quotation_child_remove_item(self): + sq = frappe.copy_doc(test_records[0]) + sq.submit() + po = make_purchase_order(sq.name) + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + }, + ] + ) + po.get("items")[0].schedule_date = add_days(today(), 1) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + po.submit() + sq.reload() + + trans_item = json.dumps( + [ + { + "item_code": "_Test Item 2", + "rate": 300, + "qty": 3, + } + ] + ) + + frappe.db.savepoint("before_cancel") + # check if item having purchase order can be removed + self.assertRaises( + frappe.LinkExistsError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name + ) + frappe.db.rollback(save_point="before_cancel") + + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": sq.items[0].rate, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + } + ] + ) + + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(len(sq.get("items")), 1) + def test_supplier_quotation_qty(self): sq = frappe.copy_doc(test_records[0]) sq.items[0].qty = 0 diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 66a3dd93169..6e1a660d870 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3678,7 +3678,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor")) child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor - if child_doctype == "Purchase Order Item": + if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]: # Initialized value will update in parent validation child_item.base_rate = 1 child_item.base_amount = 1 @@ -3696,7 +3696,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child return child_item -def validate_child_on_delete(row, parent): +def validate_child_on_delete(row, parent, ordered_item=None): """Check if partially transacted item (row) is being deleted.""" if parent.doctype == "Sales Order": if flt(row.delivered_qty): @@ -3724,13 +3724,17 @@ def validate_child_on_delete(row, parent): row.idx, row.item_code ) ) - - if flt(row.billed_amt): - frappe.throw( - _("Row #{0}: Cannot delete item {1} which has already been billed.").format( - row.idx, row.item_code + if parent.doctype in ["Purchase Order", "Sales Order"]: + if flt(row.billed_amt): + frappe.throw( + _("Row #{0}: Cannot delete item {1} which has already been billed.").format( + row.idx, row.item_code + ) ) - ) + + if parent.doctype == "Quotation": + if ordered_item.get(row.name): + frappe.throw(_("Cannot delete an item which has been ordered")) def update_bin_on_delete(row, doctype): @@ -3756,7 +3760,7 @@ def update_bin_on_delete(row, doctype): update_bin_qty(row.item_code, row.warehouse, qty_dict) -def validate_and_delete_children(parent, data) -> bool: +def validate_and_delete_children(parent, data, ordered_item=None) -> bool: deleted_children = [] updated_item_names = [d.get("docname") for d in data] for item in parent.items: @@ -3764,7 +3768,7 @@ def validate_and_delete_children(parent, data) -> bool: deleted_children.append(item) for d in deleted_children: - validate_child_on_delete(d, parent) + validate_child_on_delete(d, parent, ordered_item) d.cancel() d.delete() @@ -3773,16 +3777,19 @@ def validate_and_delete_children(parent, data) -> bool: # need to update ordered qty in Material Request first # bin uses Material Request Items to recalculate & update - parent.update_prevdoc_status() - - for d in deleted_children: - update_bin_on_delete(d, parent.doctype) + if parent.doctype not in ["Quotation", "Supplier Quotation"]: + parent.update_prevdoc_status() + for d in deleted_children: + update_bin_on_delete(d, parent.doctype) return bool(deleted_children) @frappe.whitelist() def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, child_docname="items"): + from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items + from erpnext.selling.doctype.quotation.quotation import get_ordered_items + def check_doc_permissions(doc, perm_type="create"): try: doc.check_permission(perm_type) @@ -3821,7 +3828,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) def get_new_child_item(item_row): - child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item" + child_doctype = parent_doctype + " Item" return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row) def is_allowed_zero_qty(): @@ -3846,6 +3853,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): frappe.throw(_("Cannot set quantity less than received quantity")) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if (parent_doctype == "Quotation" and not ordered_items) or ( + parent_doctype == "Supplier Quotation" and not purchased_items + ): + return + + qty_to_check = ( + ordered_items.get(child_item.name) + if parent_doctype == "Quotation" + else purchased_items.get(child_item.name) + ) + if qty_to_check: + if flt(new_data.get("qty")) < qty_to_check: + frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) + def should_update_supplied_items(doc) -> bool: """Subcontracted PO can allow following changes *after submit*: @@ -3888,7 +3910,6 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"])) data = json.loads(trans_items) - any_qty_changed = False # updated to true if any item's qty changes items_added_or_removed = False # updated to true if any new item is added or removed any_conversion_factor_changed = False @@ -3896,7 +3917,16 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent = frappe.get_doc(parent_doctype, parent_doctype_name) check_doc_permissions(parent, "write") - _removed_items = validate_and_delete_children(parent, data) + + if parent_doctype == "Quotation": + ordered_items = get_ordered_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, ordered_items) + elif parent_doctype == "Supplier Quotation": + purchased_items = get_purchased_items(parent.name) + _removed_items = validate_and_delete_children(parent, data, purchased_items) + else: + _removed_items = validate_and_delete_children(parent, data) + items_added_or_removed |= _removed_items for d in data: @@ -3936,7 +3966,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil conversion_factor_unchanged = prev_con_fac == new_con_fac any_conversion_factor_changed |= not conversion_factor_unchanged date_unchanged = ( - prev_date == getdate(new_date) if prev_date and new_date else False + (prev_date == getdate(new_date) if prev_date and new_date else False) + if parent_doctype not in ["Quotation", "Supplier Quotation"] + else None ) # in case of delivery note etc if ( rate_unchanged @@ -3949,6 +3981,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil continue validate_quantity(child_item, d) + if parent_doctype in ["Quotation", "Supplier Quotation"]: + if not rate_unchanged: + frappe.throw(_("Rates cannot be modified for quoted items")) + if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True @@ -3972,18 +4008,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil rate_unchanged = prev_rate == new_rate if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty(): frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price")))) - # Amount cannot be lesser than billed amount, except for negative amounts row_rate = flt(d.get("rate"), rate_precision) - amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( - row_rate * flt(d.get("qty"), qty_precision), rate_precision - ) - if amount_below_billed_amt and row_rate > 0.0: - frappe.throw( - _( - "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." - ).format(child_item.idx, child_item.item_code) + + if parent_doctype in ["Purchase Order", "Sales Order"]: + amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt( + row_rate * flt(d.get("qty"), qty_precision), rate_precision ) + if amount_below_billed_amt and row_rate > 0.0: + frappe.throw( + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) + ) + else: + child_item.rate = row_rate else: child_item.rate = row_rate @@ -4017,26 +4056,27 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if d.get("bom_no") and parent_doctype == "Sales Order": child_item.bom_no = d.get("bom_no") - if flt(child_item.price_list_rate): - if flt(child_item.rate) > flt(child_item.price_list_rate): - # if rate is greater than price_list_rate, set margin - # or set discount - child_item.discount_percentage = 0 - child_item.margin_type = "Amount" - child_item.margin_rate_or_amount = flt( - child_item.rate - child_item.price_list_rate, - child_item.precision("margin_rate_or_amount"), - ) - child_item.rate_with_margin = child_item.rate - else: - child_item.discount_percentage = flt( - (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, - child_item.precision("discount_percentage"), - ) - child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) - child_item.margin_type = "" - child_item.margin_rate_or_amount = 0 - child_item.rate_with_margin = 0 + if parent_doctype in ["Sales Order", "Purchase Order"]: + if flt(child_item.price_list_rate): + if flt(child_item.rate) > flt(child_item.price_list_rate): + # if rate is greater than price_list_rate, set margin + # or set discount + child_item.discount_percentage = 0 + child_item.margin_type = "Amount" + child_item.margin_rate_or_amount = flt( + child_item.rate - child_item.price_list_rate, + child_item.precision("margin_rate_or_amount"), + ) + child_item.rate_with_margin = child_item.rate + else: + child_item.discount_percentage = flt( + (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0, + child_item.precision("discount_percentage"), + ) + child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate) + child_item.margin_type = "" + child_item.margin_rate_or_amount = 0 + child_item.rate_with_margin = 0 child_item.flags.ignore_validate_update_after_submit = True if new_child_flag: @@ -4058,14 +4098,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.doctype, parent.company, parent.base_grand_total ) - parent.set_payment_schedule() + if parent_doctype != "Supplier Quotation": + parent.set_payment_schedule() if parent_doctype == "Purchase Order": parent.set_tax_withholding() parent.validate_minimum_order_qty() parent.validate_budget() if parent.is_against_so(): parent.update_status_updater() - else: + elif parent_doctype == "Sales Order": parent.check_credit_limit() # reset index of child table @@ -4098,7 +4139,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}." ).format(frappe.bold(parent.name)) ) - else: # Sales Order + elif parent_doctype == "Sales Order": parent.validate_selling_price() parent.validate_for_duplicate_items() parent.validate_warehouse() @@ -4110,9 +4151,10 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent.reload() validate_workflow_conditions(parent) - parent.update_blanket_order() - parent.update_billing_percentage() - parent.set_status() + if parent_doctype in ["Purchase Order", "Sales Order"]: + parent.update_blanket_order() + parent.update_billing_percentage() + parent.set_status() parent.validate_uom_is_integer("uom", "qty") parent.validate_uom_is_integer("stock_uom", "stock_qty") diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index f0061c016bd..480ca04b6a9 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,13 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + this.frm.add_custom_button(__("Update Items"), () => { + erpnext.utils.update_child_items({ + frm: this.frm, + child_docname: "items", + cannot_add_row: false, + }); + }); } if (doc.status !== "Ordered" && this.frm.has_perm("write")) { diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index f41116203ba..7a31854d259 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -614,6 +614,7 @@ def handle_mandatory_error(e, customer, lead_name): frappe.throw(message, title=_("Mandatory Missing")) +@frappe.whitelist() def get_ordered_items(quotation: str): return frappe._dict( frappe.get_all( diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index ae756f34288..b11f23806cb 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -1,17 +1,114 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate -from erpnext.controllers.accounts_controller import InvalidQtyError +from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate +from erpnext.selling.doctype.quotation.quotation import make_sales_order from erpnext.setup.utils import get_exchange_rate test_dependencies = ["Product Bundle"] class TestQuotation(FrappeTestCase): + def test_update_child_quotation_add_item(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_1 = make_item("_Test Item") + item_2 = make_item("_Test Item 1") + + item_list = [ + {"item_code": item_1.item_code, "warehouse": "", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + first_item = qo.get("items")[0] + second_item = qo.get("items")[1] + trans_item = json.dumps( + [ + { + "item_code": first_item.item_code, + "rate": first_item.rate, + "qty": 11, + "docname": first_item.name, + }, + { + "item_code": second_item.item_code, + "rate": second_item.rate, + "qty": second_item.qty, + "docname": second_item.name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(qo.get("items")[0].qty, 11) + self.assertEqual(qo.get("items")[-1].rate, 100) + + def test_update_child_disallow_rate_change(self): + qo = make_quotation(qty=4) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": 5000, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + def test_update_child_removing_item(self): + qo = make_quotation(qty=10) + sales_order = make_sales_order(qo.name) + sales_order.delivery_date = nowdate() + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + }, + {"item_code": "_Test Item 2", "rate": 100, "qty": 7}, + ] + ) + + update_child_qty_rate("Quotation", trans_item, qo.name) + sales_order.submit() + qo.reload() + self.assertEqual(qo.status, "Partially Ordered") + + trans_item = json.dumps([{"item_code": "_Test Item 2", "rate": 100, "qty": 7}]) + + # check if items having a sales order can be removed + self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + } + ] + ) + + # remove item with no sales order + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(len(qo.get("items")), 1) + def test_quotation_qty(self): qo = make_quotation(qty=0, do_not_save=True) with self.assertRaises(InvalidQtyError): From 3bafa360b266379d8f7f34764c80c0bea7ca07c5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 20 Feb 2026 10:14:30 +0530 Subject: [PATCH 20/53] fix: sensible insufficient stock message in pick list (cherry picked from commit 1352dc79bb2128ea0dd4122bd7205e481e89cabc) --- erpnext/stock/doctype/pick_list/pick_list.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index f82c53bd14f..01816eecdb7 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -997,12 +997,11 @@ def validate_picked_materials(item_code, required_qty, locations, picked_item_de if remaining_qty > 0: if picked_item_details: frappe.msgprint( - _("{0} units of Item {1} is picked in another Pick List.").format( - remaining_qty, get_link_to_form("Item", item_code) - ), + _( + "{0} units of Item {1} is not available in any of the warehouses. Other Pick Lists exist for this item." + ).format(remaining_qty, get_link_to_form("Item", item_code)), title=_("Already Picked"), ) - else: frappe.msgprint( _("{0} units of Item {1} is not available in any of the warehouses.").format( From 97a4a5f1cc801d5372ace9b9cb0eae86950a6c7d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 20 Feb 2026 14:21:26 +0530 Subject: [PATCH 21/53] fix: update items fetches wrong item code (cherry picked from commit ba96d37c11ea06906f9177229a32e152c48ff390) --- erpnext/public/js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 989c361dd8e..293f8e1324e 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -671,7 +671,7 @@ erpnext.utils.update_child_items = function (opts) { filters: filters, }; }, - onchange: function () { + change: function () { const me = this; frm.call({ From 1bf608f8358b93a81e857f2c0be1e4032d53bf17 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 20 Feb 2026 16:40:32 +0530 Subject: [PATCH 22/53] fix: inconsistent label name between parent and child (cherry picked from commit d6e1ca0f101480ea8d4e78f63af4b39d134b88c9) # Conflicts: # erpnext/selling/doctype/sales_order_item/sales_order_item.json --- .../doctype/sales_order_item/sales_order_item.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 21054a9d81b..b4733beb142 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -523,8 +523,12 @@ "depends_on": "eval:doc.delivered_by_supplier!=1", "fieldname": "warehouse", "fieldtype": "Link", +<<<<<<< HEAD "in_list_view": 1, "label": "Delivery Warehouse", +======= + "label": "Source Warehouse", +>>>>>>> d6e1ca0f10 (fix: inconsistent label name between parent and child) "oldfieldname": "reserved_warehouse", "oldfieldtype": "Link", "options": "Warehouse", @@ -971,7 +975,11 @@ "idx": 1, "istable": 1, "links": [], +<<<<<<< HEAD "modified": "2025-02-28 09:45:44.934947", +======= + "modified": "2026-02-20 16:39:00.200328", +>>>>>>> d6e1ca0f10 (fix: inconsistent label name between parent and child) "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", From 58d969417371786e1909f5dd525b27189b3c8d95 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 20 Feb 2026 17:01:17 +0530 Subject: [PATCH 23/53] chore: resolve conflicts --- .../doctype/sales_order_item/sales_order_item.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index b4733beb142..7edb81de48b 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -523,12 +523,8 @@ "depends_on": "eval:doc.delivered_by_supplier!=1", "fieldname": "warehouse", "fieldtype": "Link", -<<<<<<< HEAD "in_list_view": 1, - "label": "Delivery Warehouse", -======= "label": "Source Warehouse", ->>>>>>> d6e1ca0f10 (fix: inconsistent label name between parent and child) "oldfieldname": "reserved_warehouse", "oldfieldtype": "Link", "options": "Warehouse", @@ -975,11 +971,7 @@ "idx": 1, "istable": 1, "links": [], -<<<<<<< HEAD - "modified": "2025-02-28 09:45:44.934947", -======= "modified": "2026-02-20 16:39:00.200328", ->>>>>>> d6e1ca0f10 (fix: inconsistent label name between parent and child) "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", From dcf4ac66bb3833a67847bb6c8280ab04ee7cffa2 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 21 Feb 2026 12:08:37 +0530 Subject: [PATCH 24/53] fix: remove supplier invoice date/posting date validation (cherry picked from commit 7cff0ba6266297092a4c678aa5d1fc40877d61df) --- erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index c53d72f9eba..43c72938f64 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -1729,10 +1729,6 @@ class PurchaseInvoice(BuyingController): project_doc.db_update() def validate_supplier_invoice(self): - if self.bill_date: - if getdate(self.bill_date) > getdate(self.posting_date): - frappe.throw(_("Supplier Invoice Date cannot be greater than Posting Date")) - if self.bill_no: if cint(frappe.db.get_single_value("Accounts Settings", "check_supplier_invoice_uniqueness")): fiscal_year = get_fiscal_year(self.posting_date, company=self.company, as_dict=True) From 5a1c61f4d94eab0f839c6006ea04163c694a0f4a Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 21 Feb 2026 01:58:14 +0530 Subject: [PATCH 25/53] refactor: `Fiscal Year` DocType cleanup (cherry picked from commit 74ac28fc70d9a4efdfa4083e2d8656ae95dbd0e1) # Conflicts: # erpnext/accounts/doctype/fiscal_year/fiscal_year.py --- .../doctype/fiscal_year/fiscal_year.py | 57 ++++++------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index f97fcf1ec34..86083114403 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -33,24 +33,6 @@ class FiscalYear(Document): self.validate_dates() self.validate_overlap() - if not self.is_new(): - year_start_end_dates = frappe.db.sql( - """select year_start_date, year_end_date - from `tabFiscal Year` where name=%s""", - (self.name), - ) - - if year_start_end_dates: - if ( - getdate(self.year_start_date) != year_start_end_dates[0][0] - or getdate(self.year_end_date) != year_start_end_dates[0][1] - ): - frappe.throw( - _( - "Cannot change Fiscal Year Start Date and Fiscal Year End Date once the Fiscal Year is saved." - ) - ) - def validate_dates(self): self.validate_from_to_dates("year_start_date", "year_end_date") if self.is_short_year: @@ -66,28 +48,20 @@ class FiscalYear(Document): frappe.exceptions.InvalidDates, ) - def on_update(self): - check_duplicate_fiscal_year(self) - frappe.cache().delete_value("fiscal_years") - - def on_trash(self): - frappe.cache().delete_value("fiscal_years") - def validate_overlap(self): - existing_fiscal_years = frappe.db.sql( - """select name from `tabFiscal Year` - where ( - (%(year_start_date)s between year_start_date and year_end_date) - or (%(year_end_date)s between year_start_date and year_end_date) - or (year_start_date between %(year_start_date)s and %(year_end_date)s) - or (year_end_date between %(year_start_date)s and %(year_end_date)s) - ) and name!=%(name)s""", - { - "year_start_date": self.year_start_date, - "year_end_date": self.year_end_date, - "name": self.name or "No Name", - }, - as_dict=True, + fy = frappe.qb.DocType("Fiscal Year") + + name = self.name or self.year + + existing_fiscal_years = ( + frappe.qb.from_(fy) + .select(fy.name) + .where( + (fy.year_start_date <= self.year_end_date) + & (fy.year_end_date >= self.year_start_date) + & (fy.name != name) + ) + .run(as_dict=True) ) if existing_fiscal_years: @@ -110,12 +84,13 @@ class FiscalYear(Document): frappe.throw( _( "Year start date or end date is overlapping with {0}. To avoid please set company" - ).format(existing.name), + ).format(frappe.get_desk_link("Fiscal Year", existing.name, open_in_new_tab=True)), frappe.NameError, ) @frappe.whitelist() +<<<<<<< HEAD def check_duplicate_fiscal_year(doc): year_start_end_dates = frappe.db.sql( """select name, year_start_date, year_end_date from `tabFiscal Year` where name!=%s""", @@ -133,6 +108,8 @@ def check_duplicate_fiscal_year(doc): @frappe.whitelist() +======= +>>>>>>> 74ac28fc70 (refactor: `Fiscal Year` DocType cleanup) def auto_create_fiscal_year(): for d in frappe.db.sql( """select name from `tabFiscal Year` where year_end_date = date_add(current_date, interval 3 day)""" From 2fffc9448b8d38c50d238de6d14a0f8c6eca6fc8 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 21 Feb 2026 02:02:03 +0530 Subject: [PATCH 26/53] fix(`fiscal_year_company`): made `company` field mandatory (cherry picked from commit 94fb7e11b4713886dc6400fb8631dd7539ab2fd0) # Conflicts: # erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json --- .../fiscal_year_company/fiscal_year_company.json | 14 ++++++++++++-- .../fiscal_year_company/fiscal_year_company.py | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json index 67acb26c7ee..4dc16a782bc 100644 --- a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json +++ b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json @@ -15,19 +15,29 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Company", - "options": "Company" + "options": "Company", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], +<<<<<<< HEAD "modified": "2021-09-28 18:01:53.495929", +======= + "modified": "2026-02-20 23:02:26.193606", +>>>>>>> 94fb7e11b4 (fix(`fiscal_year_company`): made `company` field mandatory) "modified_by": "Administrator", "module": "Accounts", "name": "Fiscal Year Company", "owner": "Administrator", "permissions": [], +<<<<<<< HEAD "sort_field": "modified", +======= + "row_format": "Dynamic", + "sort_field": "creation", +>>>>>>> 94fb7e11b4 (fix(`fiscal_year_company`): made `company` field mandatory) "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py index 9447120d326..b68069bca27 100644 --- a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py +++ b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.py @@ -14,7 +14,7 @@ class FiscalYearCompany(Document): if TYPE_CHECKING: from frappe.types import DF - company: DF.Link | None + company: DF.Link parent: DF.Data parentfield: DF.Data parenttype: DF.Data From 397f39e27108294c44174f165dfa4ec9e3bef81e Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 21 Feb 2026 02:42:53 +0530 Subject: [PATCH 27/53] fix(`fiscal_year`): `Fiscal Year` auto-generation and notification (cherry picked from commit 4c76786ce44eb5f3813964092ac8211336c9c9eb) # Conflicts: # erpnext/accounts/doctype/fiscal_year/fiscal_year.py # erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html # erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json --- .../doctype/fiscal_year/fiscal_year.py | 28 +++++++++--- .../notification_for_new_fiscal_year.html | 43 +++++++++++++++++++ .../notification_for_new_fiscal_year.json | 19 +++++++- 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index 86083114403..befc9189016 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -4,7 +4,7 @@ import frappe from dateutil.relativedelta import relativedelta -from frappe import _ +from frappe import _, cint from frappe.model.document import Document from frappe.utils import add_days, add_years, cstr, getdate @@ -89,6 +89,7 @@ class FiscalYear(Document): ) +<<<<<<< HEAD @frappe.whitelist() <<<<<<< HEAD def check_duplicate_fiscal_year(doc): @@ -110,14 +111,27 @@ def check_duplicate_fiscal_year(doc): @frappe.whitelist() ======= >>>>>>> 74ac28fc70 (refactor: `Fiscal Year` DocType cleanup) +======= +>>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) def auto_create_fiscal_year(): - for d in frappe.db.sql( - """select name from `tabFiscal Year` where year_end_date = date_add(current_date, interval 3 day)""" - ): + fy = frappe.qb.DocType("Fiscal Year") + + # Skipped auto-creating Short Year, as it has very rare use case. + # Reference: https://www.irs.gov/businesses/small-businesses-self-employed/tax-years (US) + follow_up_date = add_days(getdate(), days=3) + fiscal_year = ( + frappe.qb.from_(fy) + .select(fy.name) + .where((fy.year_end_date == follow_up_date) & (fy.is_short_year == 0)) + .run() + ) + + for d in fiscal_year: try: current_fy = frappe.get_doc("Fiscal Year", d[0]) - new_fy = frappe.copy_doc(current_fy, ignore_no_copy=False) + new_fy = frappe.new_doc("Fiscal Year") + new_fy.disabled = cint(current_fy.disabled) new_fy.year_start_date = add_days(current_fy.year_end_date, 1) new_fy.year_end_date = add_years(current_fy.year_end_date, 1) @@ -125,6 +139,10 @@ def auto_create_fiscal_year(): start_year = cstr(new_fy.year_start_date.year) end_year = cstr(new_fy.year_end_date.year) new_fy.year = start_year if start_year == end_year else (start_year + "-" + end_year) + + for row in current_fy.companies: + new_fy.append("companies", {"company": row.company}) + new_fy.auto_created = 1 new_fy.insert(ignore_permissions=True) diff --git a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html new file mode 100644 index 00000000000..542070ab6f2 --- /dev/null +++ b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.html @@ -0,0 +1,43 @@ +

{{ _("New Fiscal Year - {0}").format(doc.name) }}

+ +

{{ _("A new fiscal year has been automatically created.") }}

+ +

{{ _("Fiscal Year Details") }}

+ + + + + + + + + + + + + + + {% if doc.companies|length > 0 %} + + + + + {% for idx in range(1, doc.companies|length) %} + + + + {% endfor %} + {% endif %} +
{{ _("Year Name") }}{{ doc.name }}
{{ _("Start Date") }}{{ frappe.format_value(doc.year_start_date) }}
{{ _("End Date") }}{{ frappe.format_value(doc.year_end_date) }}
+ {% if doc.companies|length < 2 %} + {{ _("Company") }} + {% else %} + {{ _("Companies") }} + {% endif %} + {{ doc.companies[0].company }}
{{ doc.companies[idx].company }}
+ +{% if doc.disabled %} +

{{ _("The fiscal year has been automatically created in a Disabled state to maintain consistency with the previous fiscal year's status.") }}

+{% endif %} + +

{{ _("Please review the {0} configuration and complete any required financial setup activities.").format(frappe.utils.get_link_to_form("Fiscal Year", doc.name, frappe.bold("Fiscal Year"))) }}

\ No newline at end of file diff --git a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json index 4c7faf4f65b..abcbb790a12 100644 --- a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json +++ b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json @@ -1,7 +1,8 @@ { "attach_print": 0, "channel": "Email", - "condition": "doc.auto_created", + "condition": "doc.auto_created == 1", + "condition_type": "Python", "creation": "2018-04-25 14:19:05.440361", "days_in_advance": 0, "docstatus": 0, @@ -11,8 +12,15 @@ "event": "New", "idx": 0, "is_standard": 1, +<<<<<<< HEAD "message": "

{{_(\"Fiscal Year\")}}

\n\n

{{ _(\"New fiscal year created :- \") }} {{ doc.name }}

", "modified": "2018-04-25 14:30:38.588534", +======= + "message": "

{{ _(\"New Fiscal Year - {0}\").format(doc.name) }}

\n\n

{{ _(\"A new fiscal year has been automatically created.\") }}

\n\n

{{ _(\"Fiscal Year Details\") }}

\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n {% if doc.companies|length > 0 %}\n \n \n \n \n {% for idx in range(1, doc.companies|length) %}\n \n \n \n {% endfor %}\n {% endif %}\n
{{ _(\"Year Name\") }}{{ doc.name }}
{{ _(\"Start Date\") }}{{ frappe.format_value(doc.year_start_date) }}
{{ _(\"End Date\") }}{{ frappe.format_value(doc.year_end_date) }}
\n {% if doc.companies|length < 2 %}\n {{ _(\"Company\") }}\n {% else %}\n {{ _(\"Companies\") }}\n {% endif %}\n {{ doc.companies[0].company }}
{{ doc.companies[idx].company }}
\n\n{% if doc.disabled %}\n

{{ _(\"The fiscal year has been automatically created in a Disabled state to maintain consistency with the previous fiscal year's status.\") }}

\n{% endif %}\n\n

{{ _(\"Please review the {0} configuration and complete any required financial setup activities.\").format(frappe.utils.get_link_to_form(\"Fiscal Year\", doc.name, frappe.bold(\"Fiscal Year\"))) }}

", + "message_type": "HTML", + "minutes_offset": 0, + "modified": "2026-02-21 12:14:54.736795", +>>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) "modified_by": "Administrator", "module": "Accounts", "name": "Notification for new fiscal year", @@ -25,5 +33,12 @@ "email_by_role": "Accounts Manager" } ], +<<<<<<< HEAD "subject": "Notification for new fiscal year {{ doc.name }}" -} \ No newline at end of file +} +======= + "send_system_notification": 0, + "send_to_all_assignees": 0, + "subject": "{{ _(\"New Fiscal Year {0} - Review Required\").format(doc.name) }}" +} +>>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) From bb8e5adadc24e1659fbf8922acbddf675b93f96b Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sat, 21 Feb 2026 16:36:58 +0530 Subject: [PATCH 28/53] chore: resolve conflicts --- .../doctype/fiscal_year/fiscal_year.py | 24 ------------------- .../fiscal_year_company.json | 9 ------- .../notification_for_new_fiscal_year.json | 20 ++++------------ 3 files changed, 4 insertions(+), 49 deletions(-) diff --git a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py index befc9189016..38f3a91c8fe 100644 --- a/erpnext/accounts/doctype/fiscal_year/fiscal_year.py +++ b/erpnext/accounts/doctype/fiscal_year/fiscal_year.py @@ -89,30 +89,6 @@ class FiscalYear(Document): ) -<<<<<<< HEAD -@frappe.whitelist() -<<<<<<< HEAD -def check_duplicate_fiscal_year(doc): - year_start_end_dates = frappe.db.sql( - """select name, year_start_date, year_end_date from `tabFiscal Year` where name!=%s""", - (doc.name), - ) - for fiscal_year, ysd, yed in year_start_end_dates: - if (getdate(doc.year_start_date) == ysd and getdate(doc.year_end_date) == yed) and ( - not frappe.flags.in_test - ): - frappe.throw( - _( - "Fiscal Year Start Date and Fiscal Year End Date are already set in Fiscal Year {0}" - ).format(fiscal_year) - ) - - -@frappe.whitelist() -======= ->>>>>>> 74ac28fc70 (refactor: `Fiscal Year` DocType cleanup) -======= ->>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) def auto_create_fiscal_year(): fy = frappe.qb.DocType("Fiscal Year") diff --git a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json index 4dc16a782bc..d1f1ecc0a09 100644 --- a/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json +++ b/erpnext/accounts/doctype/fiscal_year_company/fiscal_year_company.json @@ -22,22 +22,13 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], -<<<<<<< HEAD - "modified": "2021-09-28 18:01:53.495929", -======= "modified": "2026-02-20 23:02:26.193606", ->>>>>>> 94fb7e11b4 (fix(`fiscal_year_company`): made `company` field mandatory) "modified_by": "Administrator", "module": "Accounts", "name": "Fiscal Year Company", "owner": "Administrator", "permissions": [], -<<<<<<< HEAD "sort_field": "modified", -======= - "row_format": "Dynamic", - "sort_field": "creation", ->>>>>>> 94fb7e11b4 (fix(`fiscal_year_company`): made `company` field mandatory) "sort_order": "DESC", "track_changes": 1 } diff --git a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json index abcbb790a12..9160ebbae3e 100644 --- a/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json +++ b/erpnext/accounts/notification/notification_for_new_fiscal_year/notification_for_new_fiscal_year.json @@ -2,7 +2,6 @@ "attach_print": 0, "channel": "Email", "condition": "doc.auto_created == 1", - "condition_type": "Python", "creation": "2018-04-25 14:19:05.440361", "days_in_advance": 0, "docstatus": 0, @@ -12,33 +11,22 @@ "event": "New", "idx": 0, "is_standard": 1, -<<<<<<< HEAD - "message": "

{{_(\"Fiscal Year\")}}

\n\n

{{ _(\"New fiscal year created :- \") }} {{ doc.name }}

", - "modified": "2018-04-25 14:30:38.588534", -======= "message": "

{{ _(\"New Fiscal Year - {0}\").format(doc.name) }}

\n\n

{{ _(\"A new fiscal year has been automatically created.\") }}

\n\n

{{ _(\"Fiscal Year Details\") }}

\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n {% if doc.companies|length > 0 %}\n \n \n \n \n {% for idx in range(1, doc.companies|length) %}\n \n \n \n {% endfor %}\n {% endif %}\n
{{ _(\"Year Name\") }}{{ doc.name }}
{{ _(\"Start Date\") }}{{ frappe.format_value(doc.year_start_date) }}
{{ _(\"End Date\") }}{{ frappe.format_value(doc.year_end_date) }}
\n {% if doc.companies|length < 2 %}\n {{ _(\"Company\") }}\n {% else %}\n {{ _(\"Companies\") }}\n {% endif %}\n {{ doc.companies[0].company }}
{{ doc.companies[idx].company }}
\n\n{% if doc.disabled %}\n

{{ _(\"The fiscal year has been automatically created in a Disabled state to maintain consistency with the previous fiscal year's status.\") }}

\n{% endif %}\n\n

{{ _(\"Please review the {0} configuration and complete any required financial setup activities.\").format(frappe.utils.get_link_to_form(\"Fiscal Year\", doc.name, frappe.bold(\"Fiscal Year\"))) }}

", "message_type": "HTML", - "minutes_offset": 0, - "modified": "2026-02-21 12:14:54.736795", ->>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) + "modified": "2026-02-21 15:59:07.775679", "modified_by": "Administrator", "module": "Accounts", "name": "Notification for new fiscal year", "owner": "Administrator", "recipients": [ { - "email_by_role": "Accounts User" + "receiver_by_role": "Accounts Manager" }, { - "email_by_role": "Accounts Manager" + "receiver_by_role": "Accounts User" } ], -<<<<<<< HEAD - "subject": "Notification for new fiscal year {{ doc.name }}" -} -======= "send_system_notification": 0, "send_to_all_assignees": 0, "subject": "{{ _(\"New Fiscal Year {0} - Review Required\").format(doc.name) }}" -} ->>>>>>> 4c76786ce4 (fix(`fiscal_year`): `Fiscal Year` auto-generation and notification) +} \ No newline at end of file From 7032197f97657d06c8a4a32ba8a401b83eb103ec Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:43:26 +0100 Subject: [PATCH 29/53] feat(Journal Entry Account): add Bank Transaction as Reference Type (backport #52760) (#52815) * feat: add Bank Transaction as Reference Type to Journal Entry Account (#52760) * feat: add Bank Transaction as Reference Type to Journal Entry Account * fix: take care of existing property setters * fix: cancelling Bank Transactions should still be possible * fix: handle blank options in patch * fix: hide Reference Due Date for Bank Transaction (cherry picked from commit 387fb1b2022d14e15da9501f96c5324da72f0c59) # Conflicts: # erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json # erpnext/patches.txt * chore: resolve conflicts --------- Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- .../bank_transaction/bank_transaction.py | 2 ++ .../journal_entry_account.json | 6 ++-- .../journal_entry_account.py | 1 + erpnext/patches.txt | 1 + ..._transaction_as_journal_entry_reference.py | 33 +++++++++++++++++++ 5 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index f9f1b54406b..0f37d3b8bf3 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -136,6 +136,8 @@ class BankTransaction(Document): self.set_status() def on_cancel(self): + self.ignore_linked_doctypes = ["GL Entry"] + for payment_entry in self.payment_entries: self.delink_payment_entry(payment_entry) diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json index a0535c4e1ca..9fc5acf306a 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.json @@ -185,7 +185,7 @@ "fieldtype": "Select", "label": "Reference Type", "no_copy": 1, - "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry", + "options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry\nBank Transaction", "search_index": 1 }, { @@ -198,7 +198,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance'])", + "depends_on": "eval:doc.reference_type&&!in_list(doc.reference_type, ['Expense Claim', 'Asset', 'Employee Loan', 'Employee Advance', 'Bank Transaction'])", "fieldname": "reference_due_date", "fieldtype": "Date", "label": "Reference Due Date", @@ -295,7 +295,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2025-11-27 12:23:33.157655", + "modified": "2026-02-19 17:01:22.642454", "modified_by": "Administrator", "module": "Accounts", "name": "Journal Entry Account", diff --git a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py index d26224103c0..d73412f8a20 100644 --- a/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py +++ b/erpnext/accounts/doctype/journal_entry_account/journal_entry_account.py @@ -55,6 +55,7 @@ class JournalEntryAccount(Document): "Fees", "Full and Final Statement", "Payment Entry", + "Bank Transaction", ] user_remark: DF.SmallText | None # end: auto-generated types diff --git a/erpnext/patches.txt b/erpnext/patches.txt index eb80eb59354..9e36329daa4 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -406,6 +406,7 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") erpnext.patches.v14_0.set_update_price_list_based_on +erpnext.patches.v15_0.add_bank_transaction_as_journal_entry_reference erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports erpnext.patches.v14_0.update_full_name_in_contract diff --git a/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py b/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py new file mode 100644 index 00000000000..cfac2ab3858 --- /dev/null +++ b/erpnext/patches/v15_0/add_bank_transaction_as_journal_entry_reference.py @@ -0,0 +1,33 @@ +import frappe + + +def execute(): + """Append Bank Transaction in custom reference_type options.""" + new_reference_type = "Bank Transaction" + property_setters = frappe.get_all( + "Property Setter", + filters={ + "doc_type": "Journal Entry Account", + "field_name": "reference_type", + "property": "options", + }, + pluck="name", + ) + + for property_setter in property_setters: + existing_value = frappe.db.get_value("Property Setter", property_setter, "value") or "" + + raw_options = [option.strip() for option in existing_value.split("\n")] + # Preserve a single leading blank (for the empty select option) but drop spurious trailing blanks + options = raw_options[:1] + [o for o in raw_options[1:] if o] + + if new_reference_type in options: + continue + + options.append(new_reference_type) + frappe.db.set_value( + "Property Setter", + property_setter, + "value", + "\n".join(options), + ) From 82bcb62b215b41d0188f5e3fc9a434ee5dd8b1eb Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Sun, 22 Feb 2026 03:00:40 +0530 Subject: [PATCH 30/53] fix: `update_stock` behaviour on selling invoices --- .../doctype/pos_invoice/pos_invoice.json | 16 +++------------- .../accounts/doctype/pos_invoice/pos_invoice.py | 2 -- .../doctype/pos_invoice/test_pos_invoice.py | 1 - .../pos_invoice_merge_log.py | 2 ++ .../doctype/pos_profile/pos_profile.json | 12 +----------- .../accounts/doctype/pos_profile/pos_profile.py | 1 - .../doctype/sales_invoice/sales_invoice.py | 7 +++---- 7 files changed, 9 insertions(+), 32 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 5870cd9c9da..42f62147129 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -60,7 +60,6 @@ "sec_warehouse", "set_warehouse", "items_section", - "update_stock", "scan_barcode", "last_scanned_warehouse", "items", @@ -574,7 +573,6 @@ "label": "Warehouse" }, { - "depends_on": "update_stock", "fieldname": "set_warehouse", "fieldtype": "Link", "label": "Source Warehouse", @@ -588,15 +586,6 @@ "oldfieldtype": "Section Break", "options": "fa fa-shopping-cart" }, - { - "default": "0", - "fieldname": "update_stock", - "fieldtype": "Check", - "label": "Update Stock", - "oldfieldname": "update_stock", - "oldfieldtype": "Check", - "print_hide": 1 - }, { "fieldname": "scan_barcode", "fieldtype": "Data", @@ -1582,7 +1571,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2025-08-04 22:22:31.471752", + "modified": "2026-02-22 04:18:50.691218", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", @@ -1627,6 +1616,7 @@ "role": "All" } ], + "row_format": "Dynamic", "search_fields": "posting_date, due_date, customer, base_grand_total, outstanding_amount", "show_name_in_global_search": 1, "sort_field": "modified", @@ -1635,4 +1625,4 @@ "timeline_field": "customer", "title_field": "title", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 0e04592aeac..6fff3c44157 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -183,7 +183,6 @@ class POSInvoice(SalesInvoice): total_taxes_and_charges: DF.Currency update_billed_amount_in_delivery_note: DF.Check update_billed_amount_in_sales_order: DF.Check - update_stock: DF.Check write_off_account: DF.Link | None write_off_amount: DF.Currency write_off_cost_center: DF.Link | None @@ -652,7 +651,6 @@ class POSInvoice(SalesInvoice): "tax_category", "ignore_pricing_rule", "company_address", - "update_stock", ): if not for_validate: self.set(fieldname, profile.get(fieldname)) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index c1be1d2eae0..56a8aac504d 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -1101,7 +1101,6 @@ def create_pos_invoice(**args): pos_inv = frappe.new_doc("POS Invoice") pos_inv.update(args) - pos_inv.update_stock = 1 pos_inv.is_pos = 1 pos_inv.pos_profile = args.pos_profile or pos_profile.name diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 43091e9bda9..05ebbdd45b4 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -146,6 +146,7 @@ class POSInvoiceMergeLog(Document): sales_invoice.is_consolidated = 1 sales_invoice.set_posting_time = 1 + sales_invoice.update_stock = 1 if not sales_invoice.posting_date: sales_invoice.posting_date = getdate(self.posting_date) @@ -174,6 +175,7 @@ class POSInvoiceMergeLog(Document): credit_note.is_consolidated = 1 credit_note.set_posting_time = 1 + credit_note.update_stock = 1 credit_note.posting_date = getdate(self.posting_date) credit_note.posting_time = get_time(self.posting_time) # TODO: return could be against multiple sales invoice which could also have been consolidated? diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 9ed39322f4f..bda915ce703 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -25,7 +25,6 @@ "validate_stock_on_save", "print_receipt_on_order_complete", "column_break_16", - "update_stock", "ignore_pricing_rule", "allow_rate_change", "allow_discount_change", @@ -297,7 +296,6 @@ "options": "Print Format" }, { - "depends_on": "update_stock", "fieldname": "warehouse", "fieldtype": "Link", "label": "Warehouse", @@ -312,14 +310,6 @@ "fieldtype": "Check", "label": "Ignore Pricing Rule" }, - { - "default": "1", - "fieldname": "update_stock", - "fieldtype": "Check", - "hidden": 1, - "label": "Update Stock", - "read_only": 1 - }, { "default": "0", "fieldname": "hide_unavailable_items", @@ -432,7 +422,7 @@ "link_fieldname": "pos_profile" } ], - "modified": "2025-04-14 15:58:20.497426", + "modified": "2026-02-22 04:17:03.308876", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.py b/erpnext/accounts/doctype/pos_profile/pos_profile.py index 2928782a647..882b8c58eee 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.py +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.py @@ -61,7 +61,6 @@ class POSProfile(Document): tax_category: DF.Link | None taxes_and_charges: DF.Link | None tc_name: DF.Link | None - update_stock: DF.Check validate_stock_on_save: DF.Check warehouse: DF.Link write_off_account: DF.Link diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 8ed23724dfb..802705f3470 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -854,9 +854,6 @@ class SalesInvoice(SellingController): if selling_price_list: self.set("selling_price_list", selling_price_list) - if not for_validate: - self.update_stock = cint(pos.get("update_stock")) - # set pos values in items for item in self.get("items"): if item.get("item_code"): @@ -1097,7 +1094,9 @@ class SalesInvoice(SellingController): d.projected_qty = bin and flt(bin[0]["projected_qty"]) or 0 def update_packing_list(self): - if cint(self.update_stock) == 1: + if self.doctype == "POS Invoice" or ( + self.doctype == "Sales Invoice" and cint(self.update_stock) == 1 + ): from erpnext.stock.doctype.packed_item.packed_item import make_packing_list make_packing_list(self) From b3bcfd5a6496dabaa64d1394a107cceae4e1192b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Thu, 19 Feb 2026 12:59:11 +0530 Subject: [PATCH 31/53] fix(manufacturing): update status for work order before calculating planned qty (cherry picked from commit 4d40c84a3131c1a6845f52f2a33ddd22828a2a0a) --- erpnext/manufacturing/doctype/work_order/work_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index a1347a8d901..782362c542b 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1607,8 +1607,8 @@ def close_work_order(work_order, status): ) ) - work_order.on_close_or_cancel() work_order.update_status(status) + work_order.on_close_or_cancel() frappe.msgprint(_("Work Order has been {0}").format(status)) work_order.notify_update() return work_order.status From 76760c2ee34bbdced00674fb73329199d5fe3700 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 23 Feb 2026 02:32:40 +0530 Subject: [PATCH 32/53] test(manufacturing): add test to validate the planned qty (cherry picked from commit cfbdfcf5156c323a4bc2f4f0d465893340869474) --- .../doctype/work_order/test_work_order.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f1c9b706ca0..c23db9aa682 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -595,6 +595,33 @@ class TestWorkOrder(FrappeTestCase): work_order1.cancel() work_order.cancel() + def test_planned_qty_updates_after_closing_work_order(self): + item_code = "_Test FG Item" + fg_warehouse = "_Test Warehouse 1 - _TC" + + planned_before = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + + wo = make_wo_order_test_record(item=item_code, fg_warehouse=fg_warehouse, qty=10) + + planned_after_submit = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + self.assertEqual(planned_after_submit, planned_before + 10) + + close_work_order(wo.name, "Closed") + + self.assertEqual(frappe.db.get_value("Work Order", wo.name, "status"), "Closed") + + planned_after_close = ( + frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": fg_warehouse}, "planned_qty") + or 0 + ) + self.assertEqual(planned_after_close, planned_before) + def test_work_order_with_non_transfer_item(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") From dada7c4aa84a636529173ebfa6159eb8b6033a9c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:43:11 +0000 Subject: [PATCH 33/53] Merge pull request #52873 from frappe/mergify/bp/version-15-hotfix/pr-52871 fix: use stock qty instead of qty when updating transferred qty in WO (backport #52871) --- erpnext/manufacturing/doctype/work_order/work_order.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 782362c542b..ea473c555cd 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1173,6 +1173,7 @@ class WorkOrder(Document): "operation": item.operation or operation, "item_code": item.item_code, "item_name": item.item_name, + "stock_uom": item.stock_uom, "description": item.description, "allow_alternative_item": item.allow_alternative_item, "required_qty": item.qty, @@ -1197,7 +1198,7 @@ class WorkOrder(Document): .select( ste_child.item_code, ste_child.original_item, - fn.Sum(ste_child.qty).as_("qty"), + fn.Sum(ste_child.transfer_qty).as_("qty"), ) .where( (ste.docstatus == 1) @@ -1227,7 +1228,7 @@ class WorkOrder(Document): .select( ste_child.item_code, ste_child.original_item, - fn.Sum(ste_child.qty).as_("qty"), + fn.Sum(ste_child.transfer_qty).as_("qty"), ) .where( (ste.docstatus == 1) From bb1a655efb4c619702043e59b3e992571c212f51 Mon Sep 17 00:00:00 2001 From: Pandiyan37 Date: Mon, 23 Feb 2026 13:25:18 +0530 Subject: [PATCH 34/53] fix(work_order): update returned qty (cherry picked from commit b7f45e6963c9fd9d62549fdff0af00547ae83d33) --- erpnext/manufacturing/doctype/work_order/work_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index ea473c555cd..12e2ac8b7f4 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -358,7 +358,7 @@ class WorkOrder(Document): if status != self.status: self.db_set("status", status) - self.update_required_items() + self.update_required_items() return status or self.status From 946c3554b1df67e50ecc2929ffdd86891093b776 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:15:00 +0000 Subject: [PATCH 35/53] fix: avoid duplicate taxes and charges rows in payment entry (backport #52178) (#52318) Co-authored-by: Dharanidharan S fix: avoid duplicate taxes and charges rows in payment entry (#52178) --- .../doctype/payment_entry/payment_entry.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 580af69c404..6f89f02a1a7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1465,16 +1465,15 @@ frappe.ui.form.on("Payment Entry", { callback: function (r) { if (!r.exc && r.message) { // set taxes table - if (r.message) { - for (let tax of r.message) { - if (tax.charge_type === "On Net Total") { - tax.charge_type = "On Paid Amount"; - } - frm.add_child("taxes", tax); + let taxes = r.message; + taxes.forEach((tax) => { + if (tax.charge_type === "On Net Total") { + tax.charge_type = "On Paid Amount"; } - frm.events.apply_taxes(frm); - frm.events.set_unallocated_amount(frm); - } + }); + frm.set_value("taxes", taxes); + frm.events.apply_taxes(frm); + frm.events.set_unallocated_amount(frm); } }, }); From e30b2f1d04f5e8346cf12c845914c06dc9c701e5 Mon Sep 17 00:00:00 2001 From: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:58:13 +0530 Subject: [PATCH 36/53] fix(manufacturing): remove delete query of job card & batch and serial no (#52840) * fix(manufacturing): remove delete query of batch and serial no * fix(manufacturing): remove delete query of job card * fix: remove delete function call for work order (cherry picked from commit 8b2a9710190a108022da0375ecf19f99504a4ba8) --- .../manufacturing/doctype/routing/test_routing.py | 1 - .../manufacturing/doctype/work_order/work_order.py | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index e069aea274a..f3f42d6551c 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -56,7 +56,6 @@ class TestRouting(FrappeTestCase): self.assertEqual(job_card_doc.total_completed_qty, 10) wo_doc.cancel() - wo_doc.delete() def test_update_bom_operation_time(self): """Update cost shouldn't update routing times.""" diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 12e2ac8b7f4..14c458015be 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -517,7 +517,6 @@ class WorkOrder(Document): self.db_set("status", "Cancelled") self.on_close_or_cancel() - self.delete_job_card() def on_close_or_cancel(self): if self.production_plan and frappe.db.exists( @@ -531,7 +530,6 @@ class WorkOrder(Document): self.update_planned_qty() self.update_ordered_qty() self.update_reserved_qty_for_production() - self.delete_auto_created_batch_and_serial_no() def create_serial_no_batch_no(self): if not (self.has_serial_no or self.has_batch_no): @@ -588,13 +586,6 @@ class WorkOrder(Document): ) ) - def delete_auto_created_batch_and_serial_no(self): - for row in frappe.get_all("Serial No", filters={"work_order": self.name}): - frappe.delete_doc("Serial No", row.name) - - for row in frappe.get_all("Batch", filters={"reference_name": self.name}): - frappe.delete_doc("Batch", row.name) - def make_serial_nos(self, args): item_details = frappe.get_cached_value( "Item", self.production_item, ["serial_no_series", "item_name", "description"], as_dict=1 @@ -1027,10 +1018,6 @@ class WorkOrder(Document): if self.actual_start_date and self.actual_end_date: self.lead_time = flt(time_diff_in_hours(self.actual_end_date, self.actual_start_date) * 60) - def delete_job_card(self): - for d in frappe.get_all("Job Card", ["name"], {"work_order": self.name}): - frappe.delete_doc("Job Card", d.name) - def validate_production_item(self): if frappe.get_cached_value("Item", self.production_item, "has_variants"): frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError) From 6b286ae03d91998d0bd7986f6824990cb9291792 Mon Sep 17 00:00:00 2001 From: ervishnucs Date: Thu, 19 Feb 2026 18:39:54 +0530 Subject: [PATCH 37/53] fix: check gl account of an associated bank account in bank transaction (cherry picked from commit 8fe0bf4ba3c50d406990751d7f3d290fbd61544e) --- .../accounts/doctype/bank_transaction/bank_transaction.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 0f37d3b8bf3..435a6b8a025 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -372,11 +372,12 @@ def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries ("unallocated_amount", "bank_account"), as_dict=True, ) + bt_bank_account = frappe.db.get_value("Bank Account", bt.bank_account, "account") - if bt.bank_account != gl_bank_account: + if bt_bank_account != gl_bank_account: frappe.throw( _("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format( - bt.bank_account, payment_entry.payment_entry, gl_bank_account + bt_bank_account, payment_entry.payment_entry, gl_bank_account ) ) From ae733cd7adba73b7d9eb40791b960977ece78f41 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Sat, 1 Nov 2025 17:32:52 +0530 Subject: [PATCH 38/53] chore: Removing unused import (cherry picked from commit 87c59f471cac0a49fed5d7d6b21db6eb5f3d2eae) --- erpnext/accounts/party.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index b8bcc3a4160..57c66d91351 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -7,18 +7,16 @@ from frappe import _, msgprint, qb, scrub from frappe.contacts.doctype.address.address import get_company_address, get_default_address from frappe.core.doctype.user_permission.user_permission import get_permitted_documents from frappe.model.utils import get_fetch_values -from frappe.query_builder.functions import Abs, Count, Date, Sum +from frappe.query_builder.functions import Abs, Date, Sum from frappe.utils import ( add_days, add_months, - add_years, cint, cstr, date_diff, flt, formatdate, get_last_day, - get_timestamp, getdate, nowdate, ) From 4b2ac626c5cd0abb7bf1ca0aca7916497a1b98d6 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Sat, 1 Nov 2025 17:55:09 +0530 Subject: [PATCH 39/53] feat: retrieve employee basic contact information (cherry picked from commit 4ad1474e32da7e729ab07cd35fc3bc2070fa7ee3) --- erpnext/setup/doctype/employee/employee.py | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 85f9e712fe0..654481917db 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -436,3 +436,28 @@ def has_upload_permission(doc, ptype="read", user=None): if get_doc_permissions(doc, user=user, ptype=ptype).get(ptype): return True return doc.user_id == user + + +@frappe.whitelist() +def get_contact_details(employee: str) -> dict: + """ + Returns basic contact details for the given employee. + + - employee_name as contact_display + - prefered_email as contact_email + - cell_number as contact_mobile + - designation as contact_designation + - department as contact_department + + :param employee: Employee docname + """ + doc: Employee = frappe.get_doc("Employee", employee) + doc.check_permission() + + return { + "contact_display": doc.get("employee_name"), + "contact_email": doc.get("prefered_email"), + "contact_mobile": doc.get("cell_number"), + "contact_designation": doc.get("designation"), + "contact_department": doc.get("department"), + } From caa03efbe11b17e55b269276ca8ce07e419f9ec6 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Sat, 1 Nov 2025 19:18:23 +0530 Subject: [PATCH 40/53] feat: retrieve employee contact details (cherry picked from commit a41297d8410eb4bd821689b77756d964aeaed280) --- erpnext/public/js/utils/party.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 958defa32c7..4e74cc3d6e7 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -314,6 +314,16 @@ erpnext.utils.get_contact_details = function (frm) { } }; +erpnext.utils.get_employee_contact_details = async function (employee) { + if (!employee) return; + + const response = await frappe.xcall("erpnext.setup.doctype.employee.employee.get_contact_details", { + employee, + }); + + return response?.message; +}; + erpnext.utils.validate_mandatory = function (frm, label, value, trigger_on) { if (!value) { frm.doc[trigger_on] = ""; From 4078e252c28e68ed392866366dc25d86084ee910 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Sat, 1 Nov 2025 20:18:52 +0530 Subject: [PATCH 41/53] refactor: fetch employee contact details in realtime (cherry picked from commit 2ea6508fa5e61442d7789f3502c4b609f695af1f) --- .../doctype/payment_entry/payment_entry.js | 4 ++ erpnext/public/js/utils/party.js | 64 +++++++++++-------- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 6f89f02a1a7..60c8e47f8f0 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -516,12 +516,16 @@ frappe.ui.form.on("Payment Entry", { frm.set_value("contact_email", ""); frm.set_value("contact_person", ""); } + if (frm.doc.payment_type && frm.doc.party_type && frm.doc.party && frm.doc.company) { if (!frm.doc.posting_date) { frappe.msgprint(__("Please select Posting Date before selecting Party")); frm.set_value("party", ""); return; } + + erpnext.utils.get_employee_contact_details(frm); + frm.set_party_account_based_on_party = true; let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 4e74cc3d6e7..1652a849850 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -293,37 +293,49 @@ erpnext.utils.set_taxes = function (frm, triggered_from_field) { erpnext.utils.get_contact_details = function (frm) { if (frm.updating_party_details) return; - if (frm.doc["contact_person"]) { - frappe.call({ - method: "frappe.contacts.doctype.contact.contact.get_contact_details", - args: { contact: frm.doc.contact_person }, - callback: function (r) { - if (r.message) frm.set_value(r.message); - }, - }); - } else { - frm.set_value({ - contact_person: "", - contact_display: "", - contact_email: "", - contact_mobile: "", - contact_phone: "", - contact_designation: "", - contact_department: "", - }); + if (!frm.doc.contact_person) { + reset_contact_fields(frm); + return; } -}; -erpnext.utils.get_employee_contact_details = async function (employee) { - if (!employee) return; - - const response = await frappe.xcall("erpnext.setup.doctype.employee.employee.get_contact_details", { - employee, + frappe.call({ + method: "frappe.contacts.doctype.contact.contact.get_contact_details", + args: { contact: frm.doc.contact_person }, + callback: function (r) { + if (r.message) frm.set_value(r.message); + }, }); - - return response?.message; }; +erpnext.utils.get_employee_contact_details = function (frm) { + if (frm.updating_party_details || frm.doc.party_type !== "Employee") return; + + if (!frm.doc.party) { + reset_contact_fields(frm); + return; + } + + frappe.call({ + method: "erpnext.setup.doctype.employee.employee.get_contact_details", + args: { employee: frm.doc.party }, + callback: function (r) { + if (r.message) frm.set_value(r.message); + }, + }); +}; + +function reset_contact_fields(frm) { + frm.set_value({ + contact_person: "", + contact_display: "", + contact_email: "", + contact_mobile: "", + contact_phone: "", + contact_designation: "", + contact_department: "", + }); +} + erpnext.utils.validate_mandatory = function (frm, label, value, trigger_on) { if (!value) { frm.doc[trigger_on] = ""; From 0866c03e209eea68518f4fd07520ab90ad928f6a Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Sun, 2 Nov 2025 19:32:34 +0530 Subject: [PATCH 42/53] refactor: add validation for missing employee parameter (cherry picked from commit b8e06b9636d55abbaa84c74acdf6b74a3e040cac) --- erpnext/setup/doctype/employee/employee.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 654481917db..60db8098fb3 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -451,6 +451,9 @@ def get_contact_details(employee: str) -> dict: :param employee: Employee docname """ + if not employee: + frappe.throw(msg=_("Employee is required"), title=_("Missing Parameter")) + doc: Employee = frappe.get_doc("Employee", employee) doc.check_permission() From 943e2c00bc92ae5c509c36d25988ca46d842b4dc Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 18 Dec 2025 18:41:31 +0530 Subject: [PATCH 43/53] fix: get employee email with priority if preferred is not set (cherry picked from commit 7b89c12470993803327edf51f57176362db93602) --- erpnext/setup/doctype/employee/employee.py | 48 +++++++++++++++------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 60db8098fb3..d6146b965b6 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -442,25 +442,43 @@ def has_upload_permission(doc, ptype="read", user=None): def get_contact_details(employee: str) -> dict: """ Returns basic contact details for the given employee. - - - employee_name as contact_display - - prefered_email as contact_email - - cell_number as contact_mobile - - designation as contact_designation - - department as contact_department - - :param employee: Employee docname """ if not employee: frappe.throw(msg=_("Employee is required"), title=_("Missing Parameter")) - doc: Employee = frappe.get_doc("Employee", employee) - doc.check_permission() + frappe.has_permission("Employee", "read", employee, throw=True) + + contact_data = frappe.db.get_value( + "Employee", + employee, + [ + "employee_name", + "prefered_email", + "company_email", + "personal_email", + "user_id", + "cell_number", + "designation", + "department", + ], + as_dict=True, + ) + + if not contact_data: + frappe.throw(msg=_("Employee {0} not found").format(employee), title=_("Not Found")) + + # Email with priority + employee_email = ( + contact_data.get("prefered_email") + or contact_data.get("company_email") + or contact_data.get("personal_email") + or contact_data.get("user_id") + ) return { - "contact_display": doc.get("employee_name"), - "contact_email": doc.get("prefered_email"), - "contact_mobile": doc.get("cell_number"), - "contact_designation": doc.get("designation"), - "contact_department": doc.get("department"), + "contact_display": contact_data.get("employee_name"), + "contact_email": employee_email, + "contact_mobile": contact_data.get("cell_number"), + "contact_designation": contact_data.get("designation"), + "contact_department": contact_data.get("department"), } From cb17dbd6164e163103458a3b1b2c4b6c93090539 Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Thu, 18 Dec 2025 18:48:09 +0530 Subject: [PATCH 44/53] refactor: use common method to get employee contacts (cherry picked from commit ec1eb6d22201bea85030529aefba7ae21cdf4ed3) --- erpnext/accounts/party.py | 14 ++------------ erpnext/setup/doctype/employee/employee.py | 7 ++++++- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 57c66d91351..6730fc98af0 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -300,19 +300,9 @@ def complete_contact_details(party_details): contact_details = frappe._dict() if party_details.party_type == "Employee": - contact_details = frappe.db.get_value( - "Employee", - party_details.party, - [ - "employee_name as contact_display", - "prefered_email as contact_email", - "cell_number as contact_mobile", - "designation as contact_designation", - "department as contact_department", - ], - as_dict=True, - ) + from erpnext.setup.doctype.employee.employee import get_contact_details as get_employee_contact + contact_details = get_employee_contact(party_details.party) contact_details.update({"contact_person": None, "contact_phone": None}) elif party_details.contact_person: contact_details = frappe.db.get_value( diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index d6146b965b6..927b42c2cca 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -6,7 +6,6 @@ from frappe.model.naming import set_name_by_naming_series from frappe.permissions import ( add_user_permission, get_doc_permissions, - has_permission, remove_user_permission, ) from frappe.utils import cstr, getdate, today, validate_email_address @@ -442,6 +441,12 @@ def has_upload_permission(doc, ptype="read", user=None): def get_contact_details(employee: str) -> dict: """ Returns basic contact details for the given employee. + + Email is selected based on the following priority: + 1. Prefered Email + 2. Company Email + 3. Personal Email + 4. User ID """ if not employee: frappe.throw(msg=_("Employee is required"), title=_("Missing Parameter")) From 773e56808a40ddae8fe8571db7869226fd1bf2db Mon Sep 17 00:00:00 2001 From: Abdeali Chharchhoda Date: Fri, 19 Dec 2025 11:39:02 +0530 Subject: [PATCH 45/53] refactor: method to get employee contact without permission check (cherry picked from commit 58cdb9503b1e44804aa57b49babd3de9d0668a9d) --- erpnext/accounts/party.py | 2 +- erpnext/setup/doctype/employee/employee.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 6730fc98af0..c4622f0b06b 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -300,7 +300,7 @@ def complete_contact_details(party_details): contact_details = frappe._dict() if party_details.party_type == "Employee": - from erpnext.setup.doctype.employee.employee import get_contact_details as get_employee_contact + from erpnext.setup.doctype.employee.employee import _get_contact_details as get_employee_contact contact_details = get_employee_contact(party_details.party) contact_details.update({"contact_person": None, "contact_phone": None}) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 927b42c2cca..d78d1993a16 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -453,6 +453,10 @@ def get_contact_details(employee: str) -> dict: frappe.has_permission("Employee", "read", employee, throw=True) + return _get_contact_details(employee) + + +def _get_contact_details(employee: str) -> dict: contact_data = frappe.db.get_value( "Employee", employee, From 07eb5c714a34c1025a8ad50c1169029a945bba66 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:54:53 +0000 Subject: [PATCH 46/53] Merge pull request #52897 from frappe/mergify/bp/version-15-hotfix/pr-52878 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: standalone sales invoice return should not fallback to item mast… (backport #52878) --- .../sales_invoice_item.json | 5 +- .../report/gross_profit/test_gross_profit.py | 1 + erpnext/controllers/selling_controller.py | 55 +++++++++++-------- erpnext/stock/stock_ledger.py | 24 ++++---- erpnext/stock/utils.py | 3 +- 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index a5b93eae931..a2f30159e95 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -840,6 +840,7 @@ "fieldtype": "Currency", "label": "Incoming Rate (Costing)", "no_copy": 1, + "non_negative": 1, "options": "Company:company:default_currency", "print_hide": 1 }, @@ -983,7 +984,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2025-03-12 16:33:55.503777", + "modified": "2026-02-23 14:37:14.853941", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", @@ -993,4 +994,4 @@ "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 49ca61e950d..35f24df015f 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -439,6 +439,7 @@ class TestGrossProfit(FrappeTestCase): qty=-1, rate=100, posting_date=nowdate(), do_not_save=True, do_not_submit=True ) sinv.is_return = 1 + sinv.items[0].allow_zero_valuation_rate = 1 sinv = sinv.save().submit() filters = frappe._dict( diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index c2a9afadcf0..a8c4a2733fc 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -483,10 +483,34 @@ class SellingController(StockController): sales_order.update_reserved_qty(so_item_rows) def set_incoming_rate(self): + def reset_incoming_rate(): + old_item = next( + ( + item + for item in (old_doc.get("items") + (old_doc.get("packed_items") or [])) + if item.name == d.name + ), + None, + ) + if old_item: + old_qty = flt(old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty")) + if ( + old_item.item_code != d.item_code + or old_item.warehouse != d.warehouse + or old_qty != qty + or old_item.serial_no != d.serial_no + or get_serial_nos(old_item.serial_and_batch_bundle) + != get_serial_nos(d.serial_and_batch_bundle) + or old_item.batch_no != d.batch_no + or get_batch_nos(old_item.serial_and_batch_bundle) + != get_batch_nos(d.serial_and_batch_bundle) + ): + d.incoming_rate = 0 + if self.doctype not in ("Delivery Note", "Sales Invoice"): return - from erpnext.stock.serial_batch_bundle import get_batch_nos + from erpnext.stock.serial_batch_bundle import get_batch_nos, get_serial_nos allow_at_arms_length_price = frappe.get_cached_value( "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" @@ -495,6 +519,8 @@ class SellingController(StockController): "Selling Settings", "set_zero_rate_for_expired_batch" ) + is_standalone = self.is_return and not self.return_against + old_doc = self.get_doc_before_save() items = self.get("items") + (self.get("packed_items") or []) for d in items: @@ -526,27 +552,7 @@ class SellingController(StockController): qty = flt(d.get("stock_qty") or d.get("actual_qty") or d.get("qty")) if old_doc: - old_item = next( - ( - item - for item in (old_doc.get("items") + (old_doc.get("packed_items") or [])) - if item.name == d.name - ), - None, - ) - if old_item: - old_qty = flt( - old_item.get("stock_qty") or old_item.get("actual_qty") or old_item.get("qty") - ) - if ( - old_item.item_code != d.item_code - or old_item.warehouse != d.warehouse - or old_qty != qty - or old_item.batch_no != d.batch_no - or get_batch_nos(old_item.serial_and_batch_bundle) - != get_batch_nos(d.serial_and_batch_bundle) - ): - d.incoming_rate = 0 + reset_incoming_rate() if ( not d.incoming_rate @@ -565,11 +571,12 @@ class SellingController(StockController): "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": d.name, - "allow_zero_valuation": d.get("allow_zero_valuation"), + "allow_zero_valuation": d.get("allow_zero_valuation_rate"), "batch_no": d.batch_no, "serial_no": d.serial_no, }, - raise_error_if_no_rate=False, + raise_error_if_no_rate=is_standalone, + fallbacks=not is_standalone, ) if ( diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ae3730d4897..d524f644a0f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1920,6 +1920,7 @@ def get_valuation_rate( allow_zero_rate=False, currency=None, company=None, + fallbacks=True, raise_error_if_no_rate=True, batch_no=None, serial_and_batch_bundle=None, @@ -1982,23 +1983,20 @@ def get_valuation_rate( ): return flt(last_valuation_rate[0][0]) - # If negative stock allowed, and item delivered without any incoming entry, - # system does not found any SLE, then take valuation rate from Item - valuation_rate = frappe.db.get_value("Item", item_code, "valuation_rate") - - if not valuation_rate: - # try Item Standard rate - valuation_rate = frappe.db.get_value("Item", item_code, "standard_rate") - - if not valuation_rate: - # try in price list - valuation_rate = frappe.db.get_value( + if fallbacks: + # If negative stock allowed, and item delivered without any incoming entry, + # system does not found any SLE, then take valuation rate from Item + if rate := ( + frappe.db.get_value("Item", item_code, "valuation_rate") + or frappe.db.get_value("Item", item_code, "standard_rate") + or frappe.db.get_value( "Item Price", dict(item_code=item_code, buying=1, currency=currency), "price_list_rate" ) + ): + return flt(rate) if ( not allow_zero_rate - and not valuation_rate and raise_error_if_no_rate and cint(erpnext.is_perpetual_inventory_enabled(company)) ): @@ -2028,8 +2026,6 @@ def get_valuation_rate( frappe.throw(msg=msg, title=_("Valuation Rate Missing")) - return valuation_rate - def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 58ecb24db48..0c03e350d02 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -240,7 +240,7 @@ def _create_bin(item_code, warehouse): @frappe.whitelist() -def get_incoming_rate(args, raise_error_if_no_rate=True): +def get_incoming_rate(args, raise_error_if_no_rate=True, fallbacks: bool = True): """Get Incoming Rate based on valuation method""" from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate @@ -325,6 +325,7 @@ def get_incoming_rate(args, raise_error_if_no_rate=True): args.get("allow_zero_valuation"), currency=erpnext.get_company_currency(args.get("company")), company=args.get("company"), + fallbacks=fallbacks, raise_error_if_no_rate=raise_error_if_no_rate, ) From 0ba965aae615aec7f77f3d3c28755e398a0c72db Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Mon, 23 Feb 2026 20:07:27 +0530 Subject: [PATCH 47/53] fix: restore missing `has_permission` import --- erpnext/setup/doctype/employee/employee.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index d78d1993a16..14724ead051 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -6,6 +6,7 @@ from frappe.model.naming import set_name_by_naming_series from frappe.permissions import ( add_user_permission, get_doc_permissions, + has_permission, remove_user_permission, ) from frappe.utils import cstr, getdate, today, validate_email_address From eda4462e5f4d4bbc9a4e1a9842cde4132a7e7f1e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:04:41 +0000 Subject: [PATCH 48/53] Merge pull request #52722 from frappe/mergify/bp/version-15-hotfix/pr-52720 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: wrong display_depends_on condition for item group and brand chil… (backport #52720) --- erpnext/accounts/doctype/pricing_rule/pricing_rule.json | 6 +++--- erpnext/accounts/doctype/pricing_rule/pricing_rule.py | 2 +- .../doctype/pricing_rule_brand/pricing_rule_brand.json | 6 +++--- .../pricing_rule_item_group/pricing_rule_item_group.json | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json index 7a5c6d7713b..c03796fcfe8 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.json +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.json @@ -121,7 +121,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Apply On", - "options": "\nItem Code\nItem Group\nBrand\nTransaction", + "options": "Item Code\nItem Group\nBrand\nTransaction", "reqd": 1 }, { @@ -657,7 +657,7 @@ "icon": "fa fa-gift", "idx": 1, "links": [], - "modified": "2025-08-20 11:40:07.096854", + "modified": "2026-02-17 12:24:07.553505", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule", @@ -719,4 +719,4 @@ "sort_order": "DESC", "states": [], "title_field": "title" -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index 21e5ad18d9b..14ec325bea2 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -45,7 +45,7 @@ class PricingRule(Document): apply_discount_on: DF.Literal["Grand Total", "Net Total"] apply_discount_on_rate: DF.Check apply_multiple_pricing_rules: DF.Check - apply_on: DF.Literal["", "Item Code", "Item Group", "Brand", "Transaction"] + apply_on: DF.Literal["Item Code", "Item Group", "Brand", "Transaction"] apply_recursion_over: DF.Float apply_rule_on_other: DF.Literal["", "Item Code", "Item Group", "Brand"] brands: DF.Table[PricingRuleBrand] diff --git a/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json b/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json index b631ba33c6e..12aa97f7247 100644 --- a/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json +++ b/erpnext/accounts/doctype/pricing_rule_brand/pricing_rule_brand.json @@ -20,7 +20,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "depends_on": "eval:parent.apply_on == 'Item Code'", + "depends_on": "eval:parent.apply_on == 'Brand'", "fieldname": "brand", "fieldtype": "Link", "hidden": 0, @@ -91,7 +91,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2019-03-24 14:48:59.649168", + "modified": "2026-02-17 12:17:13.073587", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule Brand", @@ -107,4 +107,4 @@ "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json b/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json index 30027ba0e6a..9df9c3189ce 100644 --- a/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json +++ b/erpnext/accounts/doctype/pricing_rule_item_group/pricing_rule_item_group.json @@ -20,7 +20,7 @@ "bold": 0, "collapsible": 0, "columns": 0, - "depends_on": "eval:parent.apply_on == 'Item Code'", + "depends_on": "eval:parent.apply_on == 'Item Group'", "fieldname": "item_group", "fieldtype": "Link", "hidden": 0, @@ -91,7 +91,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2019-03-24 14:48:59.649168", + "modified": "2026-02-17 12:16:57.778471", "modified_by": "Administrator", "module": "Accounts", "name": "Pricing Rule Item Group", @@ -107,4 +107,4 @@ "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} From 71248ff40bbeb399378a0797f268f45b224f08ae Mon Sep 17 00:00:00 2001 From: Imesha Sudasingha Date: Mon, 23 Feb 2026 20:42:08 +0530 Subject: [PATCH 49/53] Merge pull request #52544 from one-highflyer/fix/improve-reserved-serial-no-error-message fix(stock): improve error message when serial no is reserved via SRE --- .../serial_and_batch_bundle.py | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 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 24638559116..54e4c4b2f56 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 @@ -299,10 +299,20 @@ class SerialandBatchBundle(Document): for serial_no in serial_nos: if not serial_no_warehouse.get(serial_no) or serial_no_warehouse.get(serial_no) != self.warehouse: - self.throw_error_message( - f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.", - SerialNoWarehouseError, - ) + reservation = get_serial_no_reservation(self.item_code, serial_no, self.warehouse) + if reservation: + self.throw_error_message( + f"Serial No {bold(serial_no)} is in warehouse {bold(self.warehouse)}" + f" but is reserved for {reservation.voucher_type} {bold(reservation.voucher_no)}" + f" via {get_link_to_form('Stock Reservation Entry', reservation.name)}." + f" Please use an unreserved serial number or cancel the reservation.", + SerialNoWarehouseError, + ) + else: + self.throw_error_message( + f"Serial No {bold(serial_no)} is not present in the warehouse {bold(self.warehouse)}.", + SerialNoWarehouseError, + ) def validate_serial_nos_duplicate(self): # Don't inward same serial number multiple times @@ -2447,6 +2457,32 @@ def get_reserved_serial_nos_for_sre(kwargs) -> list: return [row[0] for row in query.run()] +def get_serial_no_reservation(item_code: str, serial_no: str, warehouse: str) -> _dict | None: + """Returns the Stock Reservation Entry that has reserved the given serial number, if any.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + result = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sre.name, sre.voucher_type, sre.voucher_no) + .where( + (sre.docstatus == 1) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.status.notin(["Delivered", "Cancelled", "Closed"])) + & (sre.reservation_based_on == "Serial and Batch") + & (sb_entry.serial_no == serial_no) + & (sb_entry.qty != sb_entry.delivered_qty) + ) + .limit(1) + .run(as_dict=True) + ) + + return result[0] if result else None + + def get_reserved_batches_for_pos(kwargs) -> dict: """Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices.""" From 09ba9808de9d7fb719dba136c20707554cc53d27 Mon Sep 17 00:00:00 2001 From: ravibharathi656 Date: Mon, 23 Feb 2026 17:58:47 +0530 Subject: [PATCH 50/53] fix: skip empty dimension values in exchange gain loss (cherry picked from commit 7df9d951c6784f623f0ac00f8ffb5a7eba1ff3c1) --- erpnext/accounts/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 2a977dd2c03..179126452e7 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -454,7 +454,8 @@ def _build_dimensions_dict_for_exc_gain_loss( dimensions_dict = frappe._dict() if entry and active_dimensions: for dim in active_dimensions: - dimensions_dict[dim.fieldname] = entry.get(dim.fieldname) + if entry_dimension := entry.get(dim.fieldname): + dimensions_dict[dim.fieldname] = entry_dimension return dimensions_dict From 2420122f0e89f5344f70b136c6e972c6f3651ee6 Mon Sep 17 00:00:00 2001 From: Sowmya <106989392+SowmyaArunachalam@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:47:48 +0530 Subject: [PATCH 51/53] fix(sales-order): update quotation status while cancelling sales order (#52822) * fix(sales-order): update quotation status while cancelling sales order * test: validate quotation status * chore: remove submit (cherry picked from commit d638f3e03396fab37c0f9b7e8e8e3cb31a4274f0) --- .../doctype/quotation/test_quotation.py | 25 +++++++++++++++++++ .../doctype/sales_order/sales_order.py | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index b11f23806cb..96d26b3e703 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -994,6 +994,31 @@ class TestQuotation(FrappeTestCase): so1.submit() self.assertRaises(frappe.ValidationError, so2.submit) + def test_quotation_status(self): + quotation = make_quotation() + + so1 = make_sales_order(quotation.name) + so1.delivery_date = nowdate() + so1.submit() + quotation.reload() + self.assertEqual(quotation.status, "Ordered") + so1.cancel() + + quotation.reload() + self.assertEqual(quotation.status, "Open") + + so2 = make_sales_order(quotation.name) + so2.delivery_date = nowdate() + so2.items[0].qty = 1 + so2.submit() + quotation.reload() + self.assertEqual(quotation.status, "Partially Ordered") + + so2.cancel() + + quotation.reload() + self.assertEqual(quotation.status, "Open") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 832992198b6..7ceba32232f 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -460,7 +460,7 @@ class SalesOrder(SellingController): "Unreconcile Payment Entries", ) super().on_cancel() - + super().update_prevdoc_status() # Cannot cancel closed SO if self.status == "Closed": frappe.throw(_("Closed order cannot be cancelled. Unclose to cancel.")) From 61ac18069bd0ca9159960d9c8fd79063d1935b47 Mon Sep 17 00:00:00 2001 From: ljain112 Date: Fri, 23 Jan 2026 20:30:07 +0530 Subject: [PATCH 52/53] fix: prevent precision errors in discount distribution with inclusive tax (cherry picked from commit 20682997661ff64b8beafa187aae6278e7d1b126) --- erpnext/controllers/taxes_and_totals.py | 5 +++ .../tests/test_distributed_discount.py | 38 +++++++++++++++++++ .../public/js/controllers/taxes_and_totals.js | 6 +++ 3 files changed, 49 insertions(+) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1bd55786f1f..337ffbfeb0c 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -602,6 +602,11 @@ class calculate_taxes_and_totals: else: self.grand_total_diff = 0 + # Apply rounding adjustment to grand_total_for_distributing_discount + # to prevent precision errors during discount distribution + if hasattr(self, "grand_total_for_distributing_discount") and not self.discount_amount_applied: + self.grand_total_for_distributing_discount += self.grand_total_diff + def calculate_totals(self): grand_total_diff = self.grand_total_diff diff --git a/erpnext/controllers/tests/test_distributed_discount.py b/erpnext/controllers/tests/test_distributed_discount.py index e4dbdbb1480..74ae69c1750 100644 --- a/erpnext/controllers/tests/test_distributed_discount.py +++ b/erpnext/controllers/tests/test_distributed_discount.py @@ -59,3 +59,41 @@ class TestTaxesAndTotals(AccountsTestMixin, FrappeTestCase): self.assertEqual(so.total, 1500) self.assertAlmostEqual(so.net_total, 1272.73, places=2) self.assertEqual(so.grand_total, 1400) + + def test_100_percent_discount_with_inclusive_tax(self): + """Test that 100% discount with inclusive taxes results in zero net_total""" + so = make_sales_order(do_not_save=1) + so.apply_discount_on = "Grand Total" + so.items[0].qty = 2 + so.items[0].rate = 1300 + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Account VAT", + "included_in_print_rate": True, + "rate": 9, + }, + ) + so.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account Service Tax - _TC", + "cost_center": "_Test Cost Center - _TC", + "description": "Account Service Tax", + "included_in_print_rate": True, + "rate": 9, + }, + ) + so.save() + + # Apply 100% discount + so.discount_amount = 2600 + calculate_taxes_and_totals(so) + + # net_total should be exactly 0, not 0.01 + self.assertEqual(so.net_total, 0) + self.assertEqual(so.grand_total, 0) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 30c4807743d..a4b4de19303 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -584,6 +584,12 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else { me.grand_total_diff = 0; } + + // Apply rounding adjustment to grand_total_for_distributing_discount + // to prevent precision errors during discount distribution + if (me.grand_total_for_distributing_discount && !me.discount_amount_applied) { + me.grand_total_for_distributing_discount += me.grand_total_diff; + } } } } From b37fc6676e3c8995bb07f77b31a259b9cf4209c9 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 25 Feb 2026 09:57:13 +0530 Subject: [PATCH 53/53] chore: clearer description for internal transfer at arms length (cherry picked from commit bd9e5e97d716f1ae64b6a2d2d89f83ad381d8780) --- erpnext/stock/doctype/stock_settings/stock_settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index ee7e652cf6e..a9a3fcbfae4 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -455,7 +455,7 @@ }, { "default": "0", - "description": "If enabled, the item rate won't adjust to the valuation rate during internal transfers, but accounting will still use the valuation rate.", + "description": "If enabled, the item rate won't adjust to the valuation rate during internal transfers, but accounting will still use the valuation rate. This will allow the user to specify a different rate for printing or taxation purposes.", "fieldname": "allow_internal_transfer_at_arms_length_price", "fieldtype": "Check", "label": "Allow Internal Transfers at Arm's Length Price" @@ -553,7 +553,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-16 10:36:59.921491", + "modified": "2026-02-25 09:56:34.105949", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings",