From b04a9e25ff916d8b80fe78a624b47155799625ac Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 18 Jun 2026 20:14:31 +0530 Subject: [PATCH 1/4] fix(stock): make pick_list link query valid on Postgres (GROUP BY joined column) get_pick_list_query selects Sales Order.customer (a joined table's column) while grouping only by Pick List.name. Postgres' functional-dependency relaxation applies to a table's own primary key, not to a joined table's columns, so the query raises GroupingError on Postgres. MariaDB arbitrary-picks and runs. customer is already pinned to a single value by `WHERE Sales Order.customer = filter`, so adding it to the GROUP BY is identical on MariaDB and valid on Postgres. Test (errors with GroupingError on the old code on Postgres, passes on both engines): - test_get_pick_list_query_postgres_valid: a submitted pick list for a customer is returned by the link query. Co-Authored-By: Claude Opus 4.8 (1M context) --- erpnext/stock/doctype/pick_list/pick_list.py | 5 ++++- .../stock/doctype/pick_list/test_pick_list.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 2429970dd50..d87e815127a 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1372,7 +1372,10 @@ def get_pick_list_query(doctype: Any, txt: str, searchfield: Any, start: int, pa .where(PICK_LIST.status.isin(["Open", "Partly Delivered"])) .where(PICK_LIST.company == filters.get("company")) .where(SALES_ORDER.customer == filters.get("customer")) - .groupby(PICK_LIST.name) + # customer is from the joined Sales Order, not Pick List's PK, so Postgres rejects it as a bare + # select under GROUP BY pick_list.name; it is pinned to one value by the filter above, so adding + # it to the GROUP BY is valid on Postgres and identical on MariaDB. + .groupby(PICK_LIST.name, SALES_ORDER.customer) ) if filters.get("sales_order"): diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 7ef634cddd6..c442539f1f5 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -1964,3 +1964,23 @@ class TestPickList(ERPNextTestSuite): item_codes = [item.item_code for item in doc.items] self.assertIn(item1, item_codes) self.assertIn(item2, item_codes) + + def test_get_pick_list_query_postgres_valid(self): + """get_pick_list_query selects Sales Order.customer (a joined-table column) under + GROUP BY Pick List.name. Postgres rejects that bare column (PK functional dependency does + not cross tables), so the link query raised GroupingError. customer is pinned to one value + by the filter, so adding it to the GROUP BY is identical on MariaDB and valid on Postgres.""" + from erpnext.stock.doctype.pick_list.pick_list import get_pick_list_query + + warehouse = "_Test Warehouse - _TC" + item = make_item().name + make_stock_entry(item=item, to_warehouse=warehouse, qty=100, basic_rate=100) + so = make_sales_order(item_code=item, qty=5, rate=100) + pl = create_pick_list(so.name) + pl.submit() + + # must run without raising on either engine (GroupingError on Postgres before the fix) + result = get_pick_list_query( + "Pick List", "", "name", 0, 20, {"company": so.company, "customer": so.customer} + ) + self.assertIn(pl.name, [row["name"] for row in result]) From cfa6d286ad84465d606a473c6913ad8d96713a33 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 18 Jun 2026 20:36:30 +0530 Subject: [PATCH 2/4] fix(postgres): other-class parity fixes (case-folding & null-ordering) for merged queries Parity sweep findings in queries already merged in develop: - pos_invoice.py: serial_no return-match matched case-sensitively on Postgres (case-insensitive on MariaDB). Replaced the get_all or_filters lookup with a qb query that case-folds both sides via Lower() (no-op on MariaDB). - controllers/queries.py + pick_list.py: the Locate()-based relevance ranking in link-query ORDER BY is case-sensitive on Postgres (Strpos) vs case-insensitive on MariaDB (Locate), so autocomplete order differed. Lower() both arguments so the ranking matches on both engines. - crm/lead_conversion_time.py: "first contact" used ORDER BY communication_date LIMIT 1 read by index; a NULL date sorts first on MariaDB but last on Postgres, changing the result. Added `communication_date IS NOT NULL` so both engines return the earliest real contact date. Verified on MariaDB and Postgres: test_pick_list 40/40, test_pos_invoice 26/26 on both engines. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../doctype/pos_invoice/pos_invoice.py | 26 +++++++++++-------- erpnext/controllers/queries.py | 22 +++++++++++++--- .../lead_conversion_time.py | 2 +- erpnext/stock/doctype/pick_list/pick_list.py | 8 ++++-- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index a51b27f4b71..23ef9099b6d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -6,7 +6,7 @@ import frappe from frappe import _, bold from frappe.model.document import Document from frappe.model.mapper import map_child_doc, map_doc -from frappe.query_builder.functions import IfNull, Sum +from frappe.query_builder.functions import IfNull, Lower, Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate from frappe.utils.nestedset import get_descendants_of @@ -505,16 +505,20 @@ class POSInvoice(SalesInvoice): if d.get("serial_no"): serial_nos = get_serial_nos(d.serial_no) for sr in serial_nos: - serial_no_exists = frappe.get_all( - "POS Invoice Item", - filters={"parent": self.return_against}, - or_filters=[ - ["serial_no", "=", sr], - ["serial_no", "like", f"{sr}\n%"], - ["serial_no", "like", f"%\n{sr}"], - ["serial_no", "like", f"%\n{sr}\n%"], - ], - limit=1, + POI = frappe.qb.DocType("POS Invoice Item") + s = sr.lower() + serial_no_exists = ( + frappe.qb.from_(POI) + .select(POI.name) + .where(POI.parent == self.return_against) + .where( + (Lower(POI.serial_no) == s) + | Lower(POI.serial_no).like(f"{s}\n%") + | Lower(POI.serial_no).like(f"%\n{s}") + | Lower(POI.serial_no).like(f"%\n{s}\n%") + ) + .limit(1) + .run() ) if not serial_no_exists: diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 2ae16c19d17..1903bc43bcb 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -10,7 +10,7 @@ from frappe import qb, scrub from frappe.desk.reportview import get_filters_cond, get_match_cond from frappe.permissions import has_permission from frappe.query_builder import Case, Criterion, DocType -from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Substring, Sum +from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Lower, Substring, Sum from frappe.utils import nowdate, today, unique from pypika import Order @@ -313,11 +313,19 @@ def item_query( .where(date_condition) .where(Criterion.any(search_conditions)) .orderby( - Case().when(Locate(txt_no_percent, item.name) > 0, Locate(txt_no_percent, item.name)).else_(99999) + Case() + .when( + Locate(Lower(txt_no_percent), Lower(item.name)) > 0, + Locate(Lower(txt_no_percent), Lower(item.name)), + ) + .else_(99999) ) .orderby( Case() - .when(Locate(txt_no_percent, item.item_name) > 0, Locate(txt_no_percent, item.item_name)) + .when( + Locate(Lower(txt_no_percent), Lower(item.item_name)) > 0, + Locate(Lower(txt_no_percent), Lower(item.item_name)), + ) .else_(99999) ) .orderby(item.idx, order=Order.desc) @@ -406,7 +414,13 @@ def get_project_name( # ordering if txt: # project_name containing search string 'txt' will be given higher precedence - q = q.orderby(ifelse(Locate(txt, proj.project_name) > 0, Locate(txt, proj.project_name), 99999)) + q = q.orderby( + ifelse( + Locate(Lower(txt), Lower(proj.project_name)) > 0, + Locate(Lower(txt), Lower(proj.project_name)), + 99999, + ) + ) q = q.orderby(proj.idx, order=Order.desc).orderby(proj.name) if page_len: diff --git a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py index d7d964d690a..ee0fc3e75d1 100644 --- a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py +++ b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py @@ -124,7 +124,7 @@ def get_communication_details(filters): FROM `tabCommunication` WHERE - recipients = %s + recipients = %s AND communication_date IS NOT NULL ORDER BY communication_date LIMIT 1 diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index d87e815127a..a793d75f6c4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -9,7 +9,7 @@ import frappe from frappe import _, bold from frappe.model.document import Document from frappe.query_builder import Case -from frappe.query_builder.functions import Coalesce, GroupConcat, Locate, Max, Replace, Sum +from frappe.query_builder.functions import Coalesce, GroupConcat, Locate, Lower, Max, Replace, Sum from frappe.utils import cint, floor, flt, get_link_to_form from frappe.utils.nestedset import get_descendants_of @@ -1301,7 +1301,11 @@ def get_pending_work_orders( & (wo.company == filters.get("company")) & (wo.name.like(f"%{txt}%")) ) - .orderby(Case().when(Locate(txt, wo.name) > 0, Locate(txt, wo.name)).else_(99999)) + .orderby( + Case() + .when(Locate(Lower(txt), Lower(wo.name)) > 0, Locate(Lower(txt), Lower(wo.name))) + .else_(99999) + ) .orderby(wo.name) .limit(cint(page_length)) .offset(start) From 5787951ed1093b567e3b47816fb09e7ef8be2b87 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 18 Jun 2026 20:50:31 +0530 Subject: [PATCH 3/4] fix(crm): guard first_contact result before indexing (lead conversion time) Address review (#56105): the IS NOT NULL guard can return no rows (the count above filters on sender, this query on recipients), so [0][0] would raise IndexError. Fall back to None when empty, matching the prior behaviour for that case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crm/report/lead_conversion_time/lead_conversion_time.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py index ee0fc3e75d1..c16ea672f62 100644 --- a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py +++ b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py @@ -130,7 +130,8 @@ def get_communication_details(filters): LIMIT 1 """, (d.contact_email), - )[0][0] + ) + first_contact = first_contact[0][0] if first_contact else None duration = flt(date_diff(invoice[0][0], first_contact)) From 1f4702bde7b2bdee2b4f5529af9644e90ceff6ec Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 18 Jun 2026 21:04:33 +0530 Subject: [PATCH 4/4] fix(crm): skip rows with no first-contact date in lead conversion time Address review (#56105): when there's no matching communication, first_contact is None and date_diff(invoice_date, None) treats None as today, giving a wrong (negative) duration. Skip the entry instead, mirroring the communication_count guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- erpnext/crm/report/lead_conversion_time/lead_conversion_time.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py index c16ea672f62..f11b54a0be6 100644 --- a/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py +++ b/erpnext/crm/report/lead_conversion_time/lead_conversion_time.py @@ -132,6 +132,8 @@ def get_communication_details(filters): (d.contact_email), ) first_contact = first_contact[0][0] if first_contact else None + if not first_contact: + continue duration = flt(date_diff(invoice[0][0], first_contact))