From a49e2de8667e3a4fded04338be9c5ff4cbb6bba0 Mon Sep 17 00:00:00 2001 From: Assem Bahnasy Date: Mon, 27 Apr 2026 06:45:16 +0000 Subject: [PATCH 01/19] fix: use RecoverableErrors isinstance check for repost timeout status When a Repost Item Valuation job is killed by an RQ worker timeout (JobTimeoutException raised via SIGALRM), the existing status detection relied solely on traceback string matching for 'timeout' or 'Deadlock'. This is unreliable because SIGALRM can interrupt a C-extension call (e.g. inside pypika's copy.copy()) before Python records the exception in the traceback. In that case the traceback shows only the interrupted frame -- not JobTimeoutException -- so the job is permanently marked 'Failed' instead of 'In Progress', preventing the scheduler from automatically retrying it. RecoverableErrors = (JobTimeoutException, QueryDeadlockError, QueryTimeoutError) is already defined at the top of this file and is already used further down in the same except block to suppress email notifications. Extend its use to also guard the status decision. The traceback string fallback is kept as a secondary check for forward compatibility with other timeout signals. Fixes: jobs permanently stuck as 'Failed' after RQ worker timeout, requiring manual re-queue to resume reposting. --- .../repost_item_valuation/repost_item_valuation.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index bfc857ed80b..1fc0f19a4d5 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -356,8 +356,15 @@ def repost(doc): message = message.get("message") status = "Failed" - # If failed because of timeout, set status to In Progress - if traceback and ("timeout" in traceback.lower() or "Deadlock found" in traceback): + # If failed because of a recoverable error (timeout, deadlock), set status to In Progress + # so the scheduler automatically retries instead of leaving it permanently failed. + # NOTE: isinstance check comes first because the traceback string matching is unreliable + # when SIGALRM kills the process mid-C-extension (JobTimeoutException may not appear + # in the traceback if the exception handler itself was interrupted). + traceback_lower = traceback.lower() if traceback else "" + if isinstance(e, RecoverableErrors) or ( + traceback_lower and ("timeout" in traceback_lower or "deadlock found" in traceback_lower) + ): status = "In Progress" if traceback: From 698b0879971d1c98686f1bf25982ea73653cef16 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:55:36 +0530 Subject: [PATCH 02/19] fix: copy project from first row to new rows (backport #53295) (#54619) fix: copy project to new item row from parent (cherry picked from commit 68cc5184975d0beb9786999e53c111da954544f4) Co-authored-by: ravibharathi656 --- .../purchase_invoice/purchase_invoice.js | 15 ++++++++------- .../doctype/sales_invoice/sales_invoice.js | 14 ++++++++------ .../doctype/purchase_order/purchase_order.js | 18 +++++++++++++----- .../selling/doctype/sales_order/sales_order.js | 18 ++++++++++++++++++ .../doctype/delivery_note/delivery_note.js | 9 +++++++++ .../purchase_receipt/purchase_receipt.js | 12 +++++++----- 6 files changed, 63 insertions(+), 23 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 52f68180267..c3836a30b63 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -460,13 +460,14 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - this.frm.script_manager.copy_from_first_row("items", row, [ - "expense_account", - "discount_account", - "cost_center", - "project", - ]); + const row = frappe.get_doc(cdt, cdn); + const field_copy = ["expense_account", "discount_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } on_submit() { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index b4b49828a58..800a97358f2 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -540,12 +540,14 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - this.frm.script_manager.copy_from_first_row("items", row, [ - "income_account", - "discount_account", - "cost_center", - ]); + const row = frappe.get_doc(cdt, cdn); + const field_copy = ["income_account", "discount_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } set_dynamic_labels() { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index e55ebb52b58..e77f4b3cdd7 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -705,12 +705,20 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( } items_add(doc, cdt, cdn) { - var row = frappe.get_doc(cdt, cdn); - if (doc.schedule_date) { - row.schedule_date = doc.schedule_date; - refresh_field("schedule_date", cdn, "items"); + const row = frappe.get_doc(cdt, cdn); + const field_copy = []; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); } else { - this.frm.script_manager.copy_from_first_row("items", row, ["schedule_date"]); + field_copy.push("project"); + } + if (doc.schedule_date) { + frappe.model.set_value(cdt, cdn, "schedule_date", doc.schedule_date); + } else { + field_copy.push("schedule_date"); + } + if (field_copy.length) { + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } } diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 1316252005f..c00465c3007 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -833,6 +833,24 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex this.order_type(doc); } + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + const field_copy = []; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + if (doc.delivery_date) { + frappe.model.set_value(cdt, cdn, "delivery_date", doc.delivery_date); + } else { + field_copy.push("delivery_date"); + } + if (field_copy.length) { + this.frm.script_manager.copy_from_first_row("items", row, field_copy); + } + } + create_pick_list() { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index cdb219180ad..1b7f147f30c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -372,6 +372,15 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends ( }); } + items_add(doc, cdt, cdn) { + const row = frappe.get_doc(cdt, cdn); + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + this.frm.script_manager.copy_from_first_row("items", row, ["project"]); + } + } + make_sales_invoice() { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 60981047d9e..78567b3b0d8 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -368,11 +368,13 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend 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", - ]); + const field_copy = ["expense_account", "cost_center"]; + if (doc.project) { + frappe.model.set_value(cdt, cdn, "project", doc.project); + } else { + field_copy.push("project"); + } + this.frm.script_manager.copy_from_first_row("items", row, field_copy); } }; From d64b19416e47d177faef2051d7f02ecf9b438a95 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:45:18 +0000 Subject: [PATCH 03/19] fix(selling): blanket order ordered qty recalculation on sales order status change (backport #54593) (#54622) fix(selling): blanket order ordered qty recalculation on sales order status change (#54593) (cherry picked from commit d68801e73a1975ed89e9797cd2d64ded99847b0b) Co-authored-by: Pandiyan P --- erpnext/selling/doctype/sales_order/sales_order.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 85f9e246b04..a6babe5516c 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -533,6 +533,7 @@ class SalesOrder(SellingController): self.update_reserved_qty() self.notify_update() clear_doctype_notifications(self) + self.update_blanket_order() def update_reserved_qty(self, so_item_rows=None): """update requested qty (before ordered_qty is updated)""" From 559b31baae6a61df74676991740477f6b5b68124 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:55:06 +0530 Subject: [PATCH 04/19] fix: show correct status in Serial No Ledger (backport #54567) (#54625) * refactor: extract SN status logic (cherry picked from commit cb2e6e1e2ea32e62a629c1323f5903045d4cde96) * fix: show correct status in Serial No Ledger (cherry picked from commit 2b3e047143405cb3f810553b7ecfed505779801c) --------- Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com> --- .../serial_no_ledger/serial_no_ledger.py | 3 +- erpnext/stock/serial_batch_bundle.py | 68 +++++++++++-------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index c927f3d6d9e..b73f8dde3b2 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -7,6 +7,7 @@ import frappe from frappe import _ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle +from erpnext.stock.serial_batch_bundle import get_serial_no_status from erpnext.stock.stock_ledger import get_stock_ledger_entries BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"] @@ -111,7 +112,7 @@ def get_data(filters): "posting_time": row.posting_time, "voucher_type": row.voucher_type, "voucher_no": row.voucher_no, - "status": "Active" if row.actual_qty > 0 else "Delivered", + "status": get_serial_no_status(row), "company": row.company, "warehouse": row.warehouse, "qty": 1 if row.actual_qty > 0 else -1, diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 84535372b4b..b2eba994538 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -15,6 +15,45 @@ from erpnext.stock.deprecated_serial_batch import ( ) from erpnext.stock.valuation import round_off_if_near_zero +CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES = ( + "Manufacture", + "Material Issue", + "Repack", + "Material Consumption for Manufacture", +) +INACTIVE_SERIAL_NO_STOCK_ENTRY_PURPOSES = ("Disassemble", "Material Receipt") + + +def get_serial_no_status(sle): + warehouse = sle.warehouse if sle.actual_qty > 0 else None + if warehouse: + return "Active" + + status = get_status_for_serial_nos(sle) + if sle.voucher_type == "Stock Entry" and sle.actual_qty < 0: + purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") + if purpose in INACTIVE_SERIAL_NO_STOCK_ENTRY_PURPOSES: + status = "Inactive" + + return status + + +def get_status_for_serial_nos(sle): + status = "Inactive" + if sle.actual_qty < 0: + status = "Delivered" + if sle.voucher_type == "Stock Entry": + purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") + if purpose in CONSUMED_SERIAL_NO_STOCK_ENTRY_PURPOSES: + status = "Consumed" + + if sle.is_cancelled == 1 and ( + sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"] or status == "Consumed" + ): + status = "Inactive" + + return status + class SerialBatchBundle: def __init__(self, **kwargs): @@ -410,25 +449,7 @@ class SerialBatchBundle: self.update_serial_no_status_warehouse(self.sle, serial_nos) def get_status_for_serial_nos(self, sle): - status = "Inactive" - if sle.actual_qty < 0: - status = "Delivered" - if sle.voucher_type == "Stock Entry": - purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") - if purpose in [ - "Manufacture", - "Material Issue", - "Repack", - "Material Consumption for Manufacture", - ]: - status = "Consumed" - - if sle.is_cancelled == 1 and ( - sle.voucher_type in ["Purchase Invoice", "Purchase Receipt"] or status == "Consumed" - ): - status = "Inactive" - - return status + return get_status_for_serial_nos(sle) def update_serial_no_status_warehouse(self, sle, serial_nos): warehouse = sle.warehouse if sle.actual_qty > 0 else None @@ -436,19 +457,12 @@ class SerialBatchBundle: if isinstance(serial_nos, str): serial_nos = [serial_nos] - status = "Active" - if not warehouse: - status = self.get_status_for_serial_nos(sle) + status = get_serial_no_status(sle) customer = None if sle.voucher_type in ["Sales Invoice", "Delivery Note"] and sle.actual_qty < 0: customer = frappe.get_cached_value(sle.voucher_type, sle.voucher_no, "customer") - if sle.voucher_type in ["Stock Entry"] and sle.actual_qty < 0: - purpose = frappe.get_cached_value("Stock Entry", sle.voucher_no, "purpose") - if purpose in ["Disassemble", "Material Receipt"]: - status = "Inactive" - sn_table = frappe.qb.DocType("Serial No") query = ( From 1b1bc3d81cfb38eec7cd138725ce89fcbfbf55b0 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:25:06 +0000 Subject: [PATCH 05/19] fix: dont show serial/batch button when PR is submitted (backport #54642) (#54645) * fix: dont show serial/batch button when PR is submitted (#54642) (cherry picked from commit 060defcc2bfd87bef7b6d3740fcc9096de8e1cbc) # Conflicts: # erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json * chore: resolve conflicts --------- Co-authored-by: Mihir Kandoi --- .../purchase_receipt_item/purchase_receipt_item.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index 8fda1c44702..1a581652d19 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -1042,7 +1042,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && parent.docstatus == 0", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" @@ -1057,7 +1057,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && parent.docstatus == 0", "fieldname": "add_serial_batch_bundle", "fieldtype": "Button", "label": "Add Serial / Batch No" @@ -1148,7 +1148,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-04-07 15:41:47.032889", + "modified": "2026-04-29 16:01:34.154697", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", From 4bab1e414262f2ec5c412ec6a408b72fa0da8817 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:35:49 +0000 Subject: [PATCH 06/19] fix(payment_entry): convert the date args to string type before escaping in `get_outstanding_reference_documents` (backport #54639) (#54647) Co-authored-by: diptanilsaha fix(payment_entry): convert the date args to string type before escaping in `get_outstanding_reference_documents` (#54639) --- erpnext/accounts/doctype/payment_entry/payment_entry.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 2723ef81d2f..098a90acbbf 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2328,16 +2328,19 @@ def get_outstanding_reference_documents(args, validate=False): } for fieldname, date_fields in date_fields_dict.items(): + from_date = frappe.db.escape(str(args.get(date_fields[0]))) if args.get(date_fields[0]) else None + to_date = frappe.db.escape(str(args.get(date_fields[1]))) if args.get(date_fields[1]) else None + if args.get(date_fields[0]) and args.get(date_fields[1]): - condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}" + condition += f" and {fieldname} between {from_date} and {to_date}" posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) elif args.get(date_fields[0]): # if only from date is supplied - condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}" + condition += f" and {fieldname} >= {from_date}" posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) elif args.get(date_fields[1]): # if only to date is supplied - condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}" + condition += f" and {fieldname} <= {to_date}" posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): From 329f4e01a3614af8cf1f1a4deadf7409399eac7b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:28:02 +0530 Subject: [PATCH 07/19] fix: correct project filter in buying doctypes (backport #54644) (#54651) fix: correct project filter in buying doctypes (#54644) (cherry picked from commit a04c0285229fe35f8ef778e0a239f06f408d77f0) Co-authored-by: Mihir Kandoi --- .../doctype/purchase_invoice/purchase_invoice.js | 6 ------ .../doctype/purchase_order/purchase_order.js | 6 ------ .../supplier_quotation/supplier_quotation.js | 6 ------ erpnext/public/js/controllers/buying.js | 16 +++++++++------- 4 files changed, 9 insertions(+), 25 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index c3836a30b63..0cb9609c6d2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -576,12 +576,6 @@ cur_frm.fields_dict["items"].grid.get_field("cost_center").get_query = function }; }; -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; - frappe.ui.form.on("Purchase Invoice", { setup: function (frm) { frm.custom_make_buttons = { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index e77f4b3cdd7..2d1c965154e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -793,12 +793,6 @@ cur_frm.cscript.update_status = function (label, status) { }); }; -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; - if (cur_frm.doc.is_old_subcontracting_flow) { cur_frm.fields_dict["items"].grid.get_field("bom").get_query = function (doc, cdt, cdn) { var d = locals[cdt][cdn]; diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index e81f9f9c988..c0fa8a54041 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -115,9 +115,3 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e // for backward compatibility: combine new and previous states extend_cscript(cur_frm.cscript, new erpnext.buying.SupplierQuotationController({ frm: cur_frm })); - -cur_frm.fields_dict["items"].grid.get_field("project").get_query = function (doc, cdt, cdn) { - return { - filters: [["Project", "status", "not in", "Completed, Cancelled"]], - }; -}; diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 37c83483c18..df2355255f4 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -25,13 +25,15 @@ erpnext.buying = { }; }); - this.frm.set_query("project", function (doc) { - return { - filters: { - company: doc.company, - }, - }; - }); + const project_filters = { + query: "erpnext.controllers.queries.get_project_name", + filters: { + company: doc.company, + }, + }; + + this.frm.set_query("project", (_) => project_filters); + this.frm.set_query("project", "items", (_, __, ___) => project_filters); if (this.frm.doc.__islocal && frappe.meta.has_field(this.frm.doc.doctype, "disable_rounded_total")) { From 393fe75363946d191ef2d3284dd2b64c8ca42984 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Thu, 30 Apr 2026 12:53:10 +0530 Subject: [PATCH 08/19] fix: show in and out qty in the stock ledger report for stock recos (cherry picked from commit da081254a6c2dd0638a51d8c73afd136ec60efe5) --- .../stock/report/stock_ledger/stock_ledger.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index 45af9474d1f..3f59dc6be3b 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -73,6 +73,7 @@ def execute(filters=None): inv_dimension_wise_dict, filters, inv_dimension_key=inv_dimension_key, opening_row=opening_row ) + item_wh_wise_prev_sle = {} for sle in sl_entries: item_detail = item_details[sle.item_code] @@ -114,6 +115,21 @@ def execute(filters=None): elif sle.voucher_type == "Stock Reconciliation": sle["in_out_rate"] = sle.valuation_rate + if ( + sle.voucher_type == "Stock Reconciliation" + and not sle.in_qty + and not sle.out_qty + and not sle.actual_qty + ): + if prev_sle := item_wh_wise_prev_sle.get((sle.item_code, sle.warehouse)): + bal_qty = prev_sle.get("qty_after_transaction", 0) + qty = sle.qty_after_transaction - bal_qty + if qty > 0: + sle.in_qty = qty + elif qty < 0: + sle.out_qty = qty + + item_wh_wise_prev_sle[(sle.item_code, sle.warehouse)] = sle data.append(sle) if include_uom: From 58d95a35ff000c0b7e65f9c1f000bab989710ac2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:35:29 +0530 Subject: [PATCH 09/19] fix(project): use user.email for invitations and skip disabled users. (backport #54561) (#54666) fix(project): use user.email for invitations and skip disabled users. (#54561) * fix(project): use user.email for invitations and skip disabled users. * Update erpnext/projects/doctype/project/project.py * fix(project): remove duplicate loop causing indentation error * fix(project): resolve pre-commit hook failure --------- (cherry picked from commit 231dd1856fd1649ea124fb622acb8480866efb50) Co-authored-by: Hemil-Sangani Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- erpnext/projects/doctype/project/project.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index bc4e4d869c0..f382118042a 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -364,13 +364,18 @@ class Project(Document): ) for user in self.users: + # process only users who haven't received the welcome email yet if user.welcome_email_sent == 0: - frappe.sendmail( - user.user, - subject=_("Project Collaboration Invitation"), - content=content, - ) - user.welcome_email_sent = 1 + # fetch canonical User data (enabled status + latest email) + user_info = frappe.db.get_value("User", user.user, ["enabled", "email"], as_dict=True) + # send email only if user is enabled and has a valid email + if user_info and user_info.enabled and user_info.email: + frappe.sendmail( + recipients=[user_info.email], + subject=_("Project Collaboration Invitation"), + content=content, + ) + user.welcome_email_sent = 1 def get_timeline_data(doctype: str, name: str) -> dict[int, int]: From 3919c3d38533c096779381d3e90f1a9d9a8c475c Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Fri, 1 May 2026 00:07:13 +0200 Subject: [PATCH 10/19] refactor: re-save Item Tax Template (#54688) --- .../item_tax_template_detail.json | 137 +++++------------- 1 file changed, 35 insertions(+), 102 deletions(-) diff --git a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json index fa40222fc66..7e487cccf19 100644 --- a/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json +++ b/erpnext/accounts/doctype/item_tax_template_detail/item_tax_template_detail.json @@ -1,108 +1,41 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2018-11-22 23:47:02.804568", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2018-11-22 23:47:02.804568", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "tax_type", + "tax_rate" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_type", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Tax", - "length": 0, - "no_copy": 0, - "options": "Account", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "tax_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax", + "options": "Account", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_rate", - "fieldtype": "Float", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Tax Rate", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "tax_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Tax Rate" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2018-12-21 23:51:39.445198", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Item Tax Template Detail", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "istable": 1, + "links": [], + "modified": "2026-04-30 23:49:27.020639", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Item Tax Template Detail", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file From a3bb40904c9a93d76513d8da2f6ba0344ddc1c5d Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 12:46:56 +0530 Subject: [PATCH 11/19] fix: incorrect expense account book in purchase return (backport #54681) (#54692) fix: incorrect expense account book in purchase return (cherry picked from commit 2a720e7008526a40af8ccf344600796750b03a93) Co-authored-by: Rohit Waghchaure --- erpnext/stock/doctype/purchase_receipt/purchase_receipt.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 16f3eb38cac..b805f9cde3d 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -673,6 +673,9 @@ class PurchaseReceipt(BuyingController): or stock_asset_rbnb ) + if self.is_return and item.expense_account: + loss_account = item.expense_account + cost_center = item.cost_center or frappe.get_cached_value( "Company", self.company, "cost_center" ) From 29282a80cf290352c3ea2c50791fc9e4ff7539e4 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 13:04:07 +0000 Subject: [PATCH 12/19] feat: copy terms attachments to transactions (backport #53403) (#54660) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- .../doctype/quotation/test_quotation.py | 91 +++++++++++++++++++ .../terms_and_conditions.json | 14 ++- .../terms_and_conditions.py | 1 + erpnext/utilities/transaction_base.py | 58 ++++++++++++ 4 files changed, 163 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 46eefff1671..d9b0b322056 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -175,6 +175,61 @@ class TestQuotation(FrappeTestCase): self.assertTrue(quotation.payment_schedule) + def test_terms_attachments_are_copied_to_quotation(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + first_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="First terms attachment", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertEqual(get_attachment_urls("Quotation", quotation.name), {first_attachment.file_url}) + + second_attachment = make_file_attachment( + "Terms and Conditions", + terms.name, + content="Second terms attachment", + ) + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + quotation_attachments = get_attachment_urls("Quotation", quotation.name) + self.assertEqual(quotation_attachments, {first_attachment.file_url}) + self.assertNotIn(second_attachment.file_url, quotation_attachments) + + new_terms = make_terms_and_conditions(copy_attachments_to_transaction=True) + new_terms_attachment = make_file_attachment( + "Terms and Conditions", + new_terms.name, + content="Attachment from updated terms", + ) + quotation.tc_name = new_terms.name + quotation.valid_till = add_days(getdate(quotation.valid_till), 1) + quotation.save() + + self.assertEqual( + get_attachment_urls("Quotation", quotation.name), + {first_attachment.file_url, new_terms_attachment.file_url}, + ) + + def test_terms_attachments_are_not_copied_when_disabled(self): + terms = make_terms_and_conditions(copy_attachments_to_transaction=False) + make_file_attachment( + "Terms and Conditions", + terms.name, + content="Terms attachment should stay on the template", + ) + + quotation = make_quotation(do_not_save=1) + quotation.tc_name = terms.name + quotation.insert() + + self.assertFalse(get_attachment_urls("Quotation", quotation.name)) + @change_settings( "Accounts Settings", {"automatically_fetch_payment_terms": 1}, @@ -1142,6 +1197,42 @@ def get_quotation_dict(party_name=None, item_code=None): } +def make_terms_and_conditions(copy_attachments_to_transaction=False): + return frappe.get_doc( + { + "doctype": "Terms and Conditions", + "title": f"_Test Terms and Conditions {frappe.generate_hash(length=8)}", + "selling": 1, + "terms": "Test terms", + "copy_attachments_to_transaction": 1 if copy_attachments_to_transaction else 0, + } + ).insert() + + +def make_file_attachment(doctype, docname, content): + return frappe.get_doc( + { + "doctype": "File", + "file_name": f"terms-attachment-{frappe.generate_hash(length=8)}.txt", + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": content, + } + ).insert() + + +def get_attachment_urls(doctype, docname): + return { + file.file_url + for file in frappe.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": docname}, + fields=["file_url"], + ) + if file.file_url + } + + def make_quotation(**args): qo = frappe.new_doc("Quotation") args = frappe._dict(args) diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json index 76e52aefeba..30598c9143b 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.json @@ -11,6 +11,8 @@ "field_order": [ "title", "disabled", + "column_break_ofhb", + "copy_attachments_to_transaction", "applicable_modules_section", "selling", "buying", @@ -72,12 +74,22 @@ { "fieldname": "section_break_7", "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_ofhb", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "copy_attachments_to_transaction", + "fieldtype": "Check", + "label": "Copy Attachments to Transaction" } ], "icon": "icon-legal", "idx": 1, "links": [], - "modified": "2024-01-30 12:47:52.325531", + "modified": "2026-04-29 22:51:49.285298", "modified_by": "Administrator", "module": "Setup", "name": "Terms and Conditions", diff --git a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py index 40c905b161c..127517a1e3f 100644 --- a/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py +++ b/erpnext/setup/doctype/terms_and_conditions/terms_and_conditions.py @@ -21,6 +21,7 @@ class TermsandConditions(Document): from frappe.types import DF buying: DF.Check + copy_attachments_to_transaction: DF.Check disabled: DF.Check selling: DF.Check terms: DF.TextEditor | None diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 7e40fd681eb..0fa68f52c16 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -15,6 +15,14 @@ class UOMMustBeIntegerError(frappe.ValidationError): class TransactionBase(StatusUpdater): + def on_change(self): + # `on_change` also fires for `db_set()`, so only run during an actual insert/save. + is_real_save = self.flags.in_insert or (self.doctype, self.name) in frappe.flags.currently_saving + if not is_real_save: + return + + self.copy_terms_and_conditions_attachments() + def validate_posting_time(self): # set Edit Posting Date and Time to 1 while data import if frappe.flags.in_import and self.posting_date: @@ -33,6 +41,56 @@ class TransactionBase(StatusUpdater): def validate_uom_is_integer(self, uom_field, qty_fields, child_dt=None): validate_uom_is_integer(self, uom_field, qty_fields, child_dt) + def copy_terms_and_conditions_attachments(self): + if ( + not self.name + or not self.meta.has_field("tc_name") + or not self.tc_name + or not self.has_value_changed("tc_name") + ): + return + + copy_attachments_to_transaction = frappe.db.get_value( + "Terms and Conditions", self.tc_name, "copy_attachments_to_transaction" + ) + if not cint(copy_attachments_to_transaction): + return + + source_attachments = frappe.get_all( + "File", + filters={ + "attached_to_doctype": "Terms and Conditions", + "attached_to_name": self.tc_name, + }, + fields=["name", "file_url"], + ) + if not source_attachments: + return + + existing_file_urls = { + attachment.file_url + for attachment in frappe.get_all( + "File", + filters={ + "attached_to_doctype": self.doctype, + "attached_to_name": self.name, + }, + fields=["file_url"], + ) + if attachment.file_url + } + + for source_attachment in source_attachments: + if not source_attachment.file_url or source_attachment.file_url in existing_file_urls: + continue + + # Reuse the existing file metadata so the same on-disk blob is shared. + new_attachment = frappe.get_doc("File", source_attachment.name).create_attachment_copy( + attached_to_doctype=self.doctype, + attached_to_name=self.name, + ) + existing_file_urls.add(new_attachment.file_url) + def validate_with_previous_doc(self, ref): self.exclude_fields = ["conversion_factor", "uom"] if self.get("is_return") else [] From 6246a9aa6e713cd86aa782b530d059ba77a989d0 Mon Sep 17 00:00:00 2001 From: Kaajalchhattani <89331214+Kaajalchhattani@users.noreply.github.com> Date: Sat, 2 May 2026 21:23:03 +0530 Subject: [PATCH 13/19] fix: set valid_from in created Item Price (#54696) Co-authored-by: Kaajal-Chhattani --- erpnext/stock/get_item_details.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 65d04cfd80c..4e13bb246f2 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -1031,6 +1031,7 @@ def insert_item_price(args): currency=args.currency, uom=args.stock_uom, price_list=args.price_list, + valid_from=transaction_date, ) item_price.insert() frappe.msgprint( @@ -1055,6 +1056,7 @@ def insert_item_price(args): "currency": args.currency, "price_list_rate": price_list_rate, "uom": args.stock_uom, + "valid_from": transaction_date, } ) item_price.insert() From 581529fd00dc3a0d42ee4f84460813011c175518 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Sun, 3 May 2026 19:25:59 +0200 Subject: [PATCH 14/19] fix: accounts and account types in German CoA "SKR 03" (#54711) --- .../verified/de_kontenplan_SKR03_gnucash.json | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json index daf2e21d78c..84b16059e99 100644 --- a/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json +++ b/erpnext/accounts/doctype/account/chart_of_accounts/verified/de_kontenplan_SKR03_gnucash.json @@ -34,6 +34,13 @@ "account_number": "0430", "account_type": "Fixed Asset" }, + "Anlagen im Bau": { + "is_group": 1, + "Andere Anlagen, Betriebs- und Geschäftsausstattung im Bau": { + "account_number": "0498", + "account_type": "Capital Work in Progress" + } + }, "Accumulated Depreciation": { "account_type": "Accumulated Depreciation" } @@ -317,13 +324,21 @@ "account_number": "3800", "account_type": "Expenses Included In Asset Valuation" }, + "Bestandsveränderungen Roh-, Hilfs- und Betriebsstoffe sowie bezogene Waren": { + "account_number": "3960", + "account_type": "Stock Adjustment" + }, "Herstellungskosten": { "account_number": "4996", "account_type": "Cost of Goods Sold" }, + "Anlagenabgänge Sachanlagen (Restbuchwert bei Buchverlust)": { + "account_number": "2310", + "account_type": "Expense Account" + }, "Verluste aus dem Abgang von Gegenständen des Anlagevermögens": { "account_number": "2320", - "account_type": "Stock Adjustment" + "account_type": "Expense Account" }, "Verwaltungskosten": { "account_number": "4997", @@ -340,7 +355,7 @@ "is_group": 1, "Abschreibungen auf Sachanlagen (ohne AfA auf Kfz und Gebäude)": { "account_number": "4830", - "account_type": "Accumulated Depreciation" + "account_type": "Depreciation" }, "Abschreibungen auf Gebäude": { "account_number": "4831", From d07d7feb3f036b9b2601f433a458d57a0ce36164 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:22:32 +0530 Subject: [PATCH 15/19] refactor: `Sales Partner Commission Summary` and `Sales Partner Transaction Summary` report (backport #54268) (#54430) Co-authored-by: diptanilsaha --- .../sales_partner_commission_summary.py | 248 +++++++++++------- .../sales_partner_transaction_summary.js | 16 +- .../sales_partner_transaction_summary.py | 174 ++++-------- 3 files changed, 216 insertions(+), 222 deletions(-) diff --git a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py index 5e07eb5d8a8..5b98c4bf386 100644 --- a/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py +++ b/erpnext/selling/report/sales_partner_commission_summary/sales_partner_commission_summary.py @@ -1,122 +1,176 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder import DocType, Field, Order +from frappe.query_builder.custom import ConstantColumn +from frappe.query_builder.utils import QueryBuilder +from frappe.utils.data import comma_or + +SALES_TRANSACTION_DOCTYPES = ["Sales Order", "Sales Invoice", "Delivery Note", "POS Invoice"] def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerCommissionSummaryReport(filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerSummaryReport: + """ + Base class to generate Sales Partner Summary related Reports. + """ - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Data", - "width": 80, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Amount"), - "fieldname": "amount", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - { - "label": _("Total Commission"), - "fieldname": "total_commission", - "fieldtype": "Currency", - "options": "currency", - "width": 120, - }, - ] + dt: DocType + date_field: str + date_label: str + columns: list + data: list + query: QueryBuilder + filters: dict - return columns + def __init__(self, filters: dict): + self.filters = filters + self.columns = [] + def run(self): + self.validate_filters() + self.prepare_columns() + self.get_data() -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" - company_currency = frappe.db.get_value("Company", filters.get("company"), "default_currency") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( + return self.columns, self.data + + def validate_filters(self): + if not self.filters.get("doctype"): + frappe.throw(_("Please select the document type first.")) + + if self.filters.get("doctype") not in SALES_TRANSACTION_DOCTYPES: + frappe.throw(_("DocType can be one of them {0}").format(comma_or(SALES_TRANSACTION_DOCTYPES))) + + if not self.filters.get("company"): + frappe.throw(_("Please select a company.")) + + if ( + self.filters.get("from_date") + and self.filters.get("to_date") + and self.filters.get("from_date") > self.filters.get("to_date") + ): + frappe.throw(_("From Date cannot be greater than To Date.")) + + self._set_date_field_and_label() + + def _set_date_field_and_label(self): + self.date_field = ( + "transaction_date" if self.filters.get("doctype") == "Sales Order" else "posting_date" + ) + self.date_label = _("Order Date") if self.date_field == "transaction_date" else _("Posting Date") + + def prepare_columns(self): """ - SELECT - name, customer, territory, {} as posting_date, base_net_total as amount, - sales_partner, commission_rate, total_commission, '{}' as currency - FROM - `tab{}` - WHERE - {} and docstatus = 1 and sales_partner is not null - and sales_partner != '' order by name desc, sales_partner - """.format(date_field, company_currency, filters.get("doctype"), conditions), - filters, - as_dict=1, - ) + Extend this method to add columns on the report. Use `make_column` to add more columns. + """ + raise NotImplementedError - return entries + def get_data(self): + self.build_report_query() + + self.data = self.query.run(as_dict=1) + + def build_report_query(self): + self._build_report_base_query() + self.extend_report_query() + self._apply_common_filters() + self.apply_filters() + + def _build_report_base_query(self): + self.dt = DocType(self.filters.get("doctype")) + + company_currency = frappe.get_cached_value("Company", self.filters.get("company"), "default_currency") + + self.query = ( + frappe.qb.from_(self.dt) + .select( + self.dt.name, + self.dt.customer, + self.dt.territory, + Field(self.date_field, "posting_date", table=self.dt), + self.dt.sales_partner, + self.dt.commission_rate, + ConstantColumn(company_currency).as_("currency"), + ) + .where( + (self.dt.docstatus == 1) & (self.dt.sales_partner.notnull()) & (self.dt.sales_partner != "") + ) + .orderby(self.dt.name, order=Order.desc) + .orderby(self.dt.sales_partner) + ) + + def extend_report_query(self): + """ + Extend this method to select more columns on the query. + """ + pass + + def _apply_common_filters(self): + for field in ["company", "customer", "territory", "sales_partner"]: + if self.filters.get(field): + self.query = self.query.where(Field(field, table=self.dt) == self.filters.get(field)) + + if self.filters.get("from_date"): + self.query = self.query.where( + Field(self.date_field, table=self.dt) >= self.filters.get("from_date") + ) + + if self.filters.get("to_date"): + self.query = self.query.where( + Field(self.date_field, table=self.dt) <= self.filters.get("to_date") + ) + + def apply_filters(self): + """ + Extend this method to add more conditions on the query. + """ + pass + + def make_column( + self, label: str, fieldname: str, fieldtype: str, width: int = 140, options: str = "", hidden: int = 0 + ): + self.columns.append( + dict( + label=label, + fieldname=fieldname, + fieldtype=fieldtype, + options=options, + width=width, + hidden=hidden, + ) + ) -def get_conditions(filters, date_field): - conditions = "1=1" +class SalesPartnerCommissionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - for field in ["company", "customer", "territory"]: - if filters.get(field): - conditions += f" and {field} = %({field})s" + self.make_column(_("Customer"), "customer", "Link", options="Customer") - if filters.get("sales_partner"): - conditions += " and sales_partner = %(sales_partner)s" + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) - if filters.get("from_date"): - conditions += f" and {date_field} >= %(from_date)s" + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") - if filters.get("to_date"): - conditions += f" and {date_field} <= %(to_date)s" + self.make_column(self.date_label, "posting_date", "Date") - return conditions + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") + + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales Partner") + + self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100) + + self.make_column(_("Total Commission"), "total_commission", "Currency", 120, "currency") + + def extend_report_query(self): + self.query = self.query.select( + self.dt.base_net_total.as_("amount"), + self.dt.total_commission, + ) diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js index f6f7c3f3cf3..e4e2199606a 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.js @@ -3,6 +3,14 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + }, { fieldname: "sales_partner", label: __("Sales Partner"), @@ -28,14 +36,6 @@ frappe.query_reports["Sales Partner Transaction Summary"] = { fieldtype: "Date", default: frappe.datetime.get_today(), }, - { - fieldname: "company", - label: __("Company"), - fieldtype: "Link", - options: "Company", - default: frappe.defaults.get_user_default("Company"), - reqd: 1, - }, { fieldname: "item_group", label: __("Item Group"), diff --git a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py index 216adde18fd..f322b89f897 100644 --- a/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py +++ b/erpnext/selling/report/sales_partner_transaction_summary/sales_partner_transaction_summary.py @@ -3,144 +3,84 @@ import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder import Case + +from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import ( + SalesPartnerSummaryReport, +) def execute(filters=None): if not filters: filters = {} - columns = get_columns(filters) - data = get_entries(filters) - - return columns, data + return SalesPartnerTransactionSummaryReport(filters=filters).run() -def get_columns(filters): - if not filters.get("doctype"): - msgprint(_("Please select the document type first"), raise_exception=1) +class SalesPartnerTransactionSummaryReport(SalesPartnerSummaryReport): + def prepare_columns(self): + self.make_column(_(self.filters.get("doctype")), "name", "Link", options=self.filters.get("doctype")) - columns = [ - { - "label": _(filters["doctype"]), - "options": filters["doctype"], - "fieldname": "name", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Customer"), - "options": "Customer", - "fieldname": "customer", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Territory"), - "options": "Territory", - "fieldname": "territory", - "fieldtype": "Link", - "width": 100, - }, - {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, - { - "label": _("Item Code"), - "fieldname": "item_code", - "fieldtype": "Link", - "options": "Item", - "width": 100, - }, - { - "label": _("Item Group"), - "fieldname": "item_group", - "fieldtype": "Link", - "options": "Item Group", - "width": 100, - }, - { - "label": _("Brand"), - "fieldname": "brand", - "fieldtype": "Link", - "options": "Brand", - "width": 100, - }, - {"label": _("Quantity"), "fieldname": "qty", "fieldtype": "Float", "width": 120}, - {"label": _("Rate"), "fieldname": "rate", "fieldtype": "Currency", "width": 120}, - {"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "width": 120}, - { - "label": _("Sales Partner"), - "options": "Sales Partner", - "fieldname": "sales_partner", - "fieldtype": "Link", - "width": 140, - }, - { - "label": _("Commission Rate %"), - "fieldname": "commission_rate", - "fieldtype": "Data", - "width": 100, - }, - {"label": _("Commission"), "fieldname": "commission", "fieldtype": "Currency", "width": 120}, - { - "label": _("Currency"), - "fieldname": "currency", - "fieldtype": "Link", - "options": "Currency", - "width": 120, - }, - ] + self.make_column(_("Customer"), "customer", "Link", options="Customer") - return columns + self.make_column(_("Currency"), "currency", "Data", 80, hidden=1) + self.make_column(_("Territory"), "territory", "Link", 100, "Territory") -def get_entries(filters): - date_field = "transaction_date" if filters.get("doctype") == "Sales Order" else "posting_date" + self.make_column(self.date_label, "posting_date", "Date") - conditions = get_conditions(filters, date_field) - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{date_field} as posting_date, dt.currency, - dt_item.base_net_rate as rate, dt_item.qty, dt_item.base_net_amount as amount, - ((dt_item.base_net_amount * dt.commission_rate) / 100) as commission, - dt_item.brand, dt.sales_partner, dt.commission_rate, dt_item.item_group, dt_item.item_code - FROM - `tab{doctype}` dt, `tab{doctype} Item` dt_item - WHERE - {cond} and dt.name = dt_item.parent and dt.docstatus = 1 - and dt.sales_partner is not null and dt.sales_partner != '' - order by dt.name desc, dt.sales_partner - """.format(date_field=date_field, doctype=filters.get("doctype"), cond=conditions), - filters, - as_dict=1, - ) + self.make_column(_("Item Code"), "item_code", "Link", 100, "Item") - return entries + self.make_column(_("Item Group"), "item_group", "Link", 100, "Item Group") + self.make_column(_("Brand"), "brand", "Link", 100, "Brand") -def get_conditions(filters, date_field): - conditions = "1=1" + self.make_column(_("Quantity"), "qty", "Float", 120) - for field in ["company", "customer", "territory", "sales_partner"]: - if filters.get(field): - conditions += f" and dt.{field} = %({field})s" + self.make_column(_("Rate"), "rate", "Currency", 120, "currency") - if filters.get("from_date"): - conditions += f" and dt.{date_field} >= %(from_date)s" + self.make_column(_("Amount"), "amount", "Currency", 120, "currency") - if filters.get("to_date"): - conditions += f" and dt.{date_field} <= %(to_date)s" + self.make_column(_("Sales Partner"), "sales_partner", "Link", options="Sales Partner") - if not filters.get("show_return_entries"): - conditions += " and dt_item.qty > 0.0" + self.make_column(_("Commission Rate %"), "commission_rate", "Data", 100) - if filters.get("brand"): - conditions += " and dt_item.brand = %(brand)s" + self.make_column(_("Commission"), "commission", "Currency", 120, "currency") - if filters.get("item_group"): - lft, rgt = frappe.get_cached_value("Item Group", filters.get("item_group"), ["lft", "rgt"]) + def extend_report_query(self): + self.dt_item = frappe.qb.DocType(f"{self.filters['doctype']} Item") - conditions += f""" and dt_item.item_group in (select name from - `tabItem Group` where lft >= {lft} and rgt <= {rgt})""" + self.query = ( + self.query.join(self.dt_item) + .on(self.dt.name == self.dt_item.parent) + .select( + self.dt_item.base_net_rate.as_("rate"), + self.dt_item.qty, + self.dt_item.base_net_amount.as_("amount"), + Case() + .when( + self.dt_item.grant_commission.eq(1), + ((self.dt_item.base_net_amount * self.dt.commission_rate) / 100), + ) + .else_(0) + .as_("commission"), + self.dt_item.brand, + self.dt_item.item_group, + self.dt_item.item_code, + ) + ) - return conditions + def apply_filters(self): + if not self.filters.get("show_return_entries"): + self.query = self.query.where(self.dt_item.qty > 0.0) + + if self.filters.get("brand"): + self.query = self.query.where(self.dt_item.brand == self.filters.get("brand")) + + if self.filters.get("item_group"): + lft, rgt = frappe.get_cached_value("Item Group", self.filters.get("item_group"), ["lft", "rgt"]) + if item_groups := frappe.get_all( + "Item Group", filters=[["lft", ">=", lft], ["rgt", "<=", rgt]], pluck="name" + ): + self.query = self.query.where(self.dt_item.item_group.isin(item_groups)) From 0b0f9d046de9162fac92139bfd327120123c589b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 4 May 2026 21:01:10 +0530 Subject: [PATCH 16/19] fix: item query in quality inspection (#54721) --- .../stock/doctype/quality_inspection/quality_inspection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 3448a8ff8de..a7733b0bf8b 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -392,10 +392,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql( f""" - SELECT distinct item_code, item_name + SELECT distinct `tab{from_doctype}`.item_code, `tab{from_doctype}`.item_name FROM `tab{from_doctype}` JOIN `tab{parent_doctype}` ON `tab{parent_doctype}`.name = `tab{from_doctype}`.parent - WHERE parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and item_code like %(txt)s + WHERE `tab{from_doctype}`.parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and `tab{from_doctype}`.item_code like %(txt)s {qi_condition} {cond} {mcond} ORDER BY item_code limit {cint(page_len)} offset {cint(start)} """, From 809feb9c0409e5a658082c88175342ce2f4b74f4 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:30:02 +0530 Subject: [PATCH 17/19] fix: error when creating quotation from CRM (backport #54722) (#54724) fix: error when creating quotation from CRM (#54722) (cherry picked from commit 2d3190effb92e88f7383006e695a91e3db633a6e) Co-authored-by: Mihir Kandoi --- erpnext/selling/doctype/quotation/quotation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 2f70260a68a..d1a61ad34a3 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -560,7 +560,9 @@ def _make_customer(source_name, ignore_permissions=False): if quotation.quotation_to == "Customer": return frappe.get_doc("Customer", quotation.party_name) elif quotation.quotation_to == "CRM Deal": - return frappe.get_doc("Customer", {"crm_deal": quotation.party_name}) + customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name}) + if customer_name: + return frappe.get_doc("Customer", customer_name) # Check if a Customer already exists for the Lead or Prospect. existing_customer = None From 9c9ecc77f8cbdc7d4f57a7eaf70a7f348cd6d628 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:25:18 +0530 Subject: [PATCH 19/19] fix: hide payment and payment request buttons based on permissions in invoices and orders (backport #53920) (#54735) Co-authored-by: ravibharathi656 Co-authored-by: Sakthivel Murugan S <129778327+ssakthivelmurugan@users.noreply.github.com> Co-authored-by: diptanilsaha fix: hide payment and payment request buttons based on permissions in invoices and orders (#53920) --- .../purchase_invoice/purchase_invoice.js | 15 +++++++++++++-- .../doctype/sales_invoice/sales_invoice.js | 18 ++++++++++-------- .../doctype/purchase_order/purchase_order.js | 8 ++++++-- .../selling/doctype/sales_order/sales_order.js | 12 +++++++----- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 0cb9609c6d2..a25af18e5fc 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -115,7 +115,12 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } } - if (doc.docstatus == 1 && doc.outstanding_amount != 0 && !doc.on_hold) { + if ( + doc.docstatus == 1 && + doc.outstanding_amount != 0 && + !doc.on_hold && + frappe.model.can_create("Payment Entry") + ) { this.frm.add_custom_button(__("Payment"), () => this.make_payment_entry(), __("Create")); cur_frm.page.set_inner_btn_group_as_primary(__("Create")); } @@ -126,7 +131,13 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } } - if (doc.docstatus == 1 && doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { + if ( + doc.docstatus == 1 && + doc.outstanding_amount > 0 && + !cint(doc.is_return) && + !doc.on_hold && + frappe.boot.user.in_create.includes("Payment Request") + ) { this.frm.add_custom_button( __("Payment Request"), function () { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 800a97358f2..b6a939a1b8b 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -94,7 +94,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm); } - if (doc.docstatus == 1 && doc.outstanding_amount != 0) { + if (doc.docstatus == 1 && doc.outstanding_amount != 0 && frappe.model.can_create("Payment Entry")) { this.frm.add_custom_button(__("Payment"), () => this.make_payment_entry(), __("Create")); this.frm.page.set_inner_btn_group_as_primary(__("Create")); } @@ -136,13 +136,15 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } if (doc.outstanding_amount > 0) { - cur_frm.add_custom_button( - __("Payment Request"), - function () { - me.make_payment_request(); - }, - __("Create") - ); + if (frappe.boot.user.in_create.includes("Payment Request")) { + this.frm.add_custom_button( + __("Payment Request"), + function () { + me.make_payment_request_with_schedule(); + }, + __("Create") + ); + } this.frm.add_custom_button( __("Invoice Discounting"), this.make_invoice_discounting.bind(this), diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 2d1c965154e..2ad2edaf119 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -440,7 +440,11 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( __("Create") ); - if (flt(doc.per_billed) < 100 && doc.status != "Delivered") { + if ( + frappe.model.can_create("Payment Entry") && + flt(doc.per_billed) < 100 && + doc.status != "Delivered" + ) { this.frm.add_custom_button( __("Payment"), () => this.make_payment_entry(), @@ -448,7 +452,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends ( ); } - if (flt(doc.per_billed) < 100) { + if (flt(doc.per_billed) < 100 && frappe.boot.user.in_create.includes("Payment Request")) { this.frm.add_custom_button( __("Payment Request"), function () { diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index c00465c3007..1452a1c1e49 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -773,11 +773,13 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } // payment request if (flt(doc.per_billed) < 100 + frappe.boot.sysdefaults.over_billing_allowance) { - this.frm.add_custom_button( - __("Payment Request"), - () => this.make_payment_request(), - __("Create") - ); + if (frappe.boot.user.in_create.includes("Payment Request")) { + this.frm.add_custom_button( + __("Payment Request"), + () => this.make_payment_request_with_schedule(), + __("Create") + ); + } if (frappe.model.can_create("Payment Entry")) { this.frm.add_custom_button(