Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ frappe.ui.form.on('Special Closing Balance', {

frm.set_query("item_code", "closing_balance_details", function(doc, cdt, cdn) {
return {
query: "erpnext.controllers.queries.item_query",
query: "av_tools.av_tools_hooks.item_search.item_query",
filters:{
"is_stock_item": 1
}
Expand Down
188 changes: 188 additions & 0 deletions av_tools/av_tools_hooks/item_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import json
import re

import frappe
from frappe import scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.desk.search import search_link as original_search_link
from frappe.desk.search import search_widget as original_search_widget
from frappe.utils import nowdate


ERP_ITEM_QUERY = "erpnext.controllers.queries.item_query"
AV_TOOLS_ITEM_QUERY = "av_tools.av_tools_hooks.item_search.item_query"


def split_search_terms(txt: str | None) -> list[str]:
if not txt or not isinstance(txt, str):
return []

return [part for part in re.split(r"\s+", txt.strip()) if part]


def route_item_query(doctype, query):
if doctype == "Item" and not query:
return AV_TOOLS_ITEM_QUERY

if query == ERP_ITEM_QUERY:
return AV_TOOLS_ITEM_QUERY

return query


@frappe.whitelist()
def search_link(
doctype,
txt,
query=None,
filters=None,
page_length=10,
searchfield=None,
reference_doctype=None,
ignore_user_permissions=False,
):
return original_search_link(
doctype=doctype,
txt=txt,
query=route_item_query(doctype, query),
filters=filters,
page_length=page_length,
searchfield=searchfield,
reference_doctype=reference_doctype,
ignore_user_permissions=ignore_user_permissions,
)


@frappe.whitelist()
def search_widget(
doctype,
txt,
query=None,
searchfield=None,
start=0,
page_length=10,
filters=None,
filter_fields=None,
as_dict=False,
reference_doctype=None,
ignore_user_permissions=False,
):
return original_search_widget(
doctype=doctype,
txt=txt,
query=route_item_query(doctype, query),
searchfield=searchfield,
start=start,
page_length=page_length,
filters=filters,
filter_fields=filter_fields,
as_dict=as_dict,
reference_doctype=reference_doctype,
ignore_user_permissions=ignore_user_permissions,
)


@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
doctype = "Item"
conditions = []
search_terms = split_search_terms(txt)
search_text = "%".join(search_terms) if search_terms else ""

if isinstance(filters, str):
filters = json.loads(filters)

meta = frappe.get_meta(doctype, cached=True)
searchfields = meta.get_search_fields()

columns = ""
extra_searchfields = [field for field in searchfields if field not in ["name", "description"]]

if extra_searchfields:
columns += ", " + ", ".join(extra_searchfields)

if "description" in searchfields:
columns += """, if(length(tabItem.description) > 40, \
concat(substr(tabItem.description, 1, 40), "..."), description) as description"""

searchfields = searchfields + [
field
for field in [searchfield or "name", "item_code", "item_group", "item_name"]
if field not in searchfields
]

def build_search_condition(field):
if len(search_terms) <= 1:
return f"{field} like %(txt)s"

return "(" + " and ".join([f"{field} like %(txt_{i})s" for i in range(len(search_terms))]) + ")"

searchfields = " or ".join([build_search_condition(field) for field in searchfields])

if filters and isinstance(filters, dict):
if filters.get("customer") or filters.get("supplier"):
party = filters.get("customer") or filters.get("supplier")
item_rules_list = frappe.get_all(
"Party Specific Item",
filters={"party": party},
fields=["restrict_based_on", "based_on_value"],
)

filters_dict = {}
for rule in item_rules_list:
if rule["restrict_based_on"] == "Item":
rule["restrict_based_on"] = "name"
filters_dict[rule.restrict_based_on] = []

for rule in item_rules_list:
filters_dict[rule.restrict_based_on].append(rule.based_on_value)

for filter in filters_dict:
filters[scrub(filter)] = ["in", filters_dict[filter]]

if filters.get("customer"):
del filters["customer"]
else:
del filters["supplier"]
else:
filters.pop("customer", None)
filters.pop("supplier", None)

description_cond = ""
if frappe.db.count(doctype, cache=True) < 50000:
description_cond = f"or {build_search_condition('tabItem.description')}"

return frappe.db.sql(
"""select
tabItem.name {columns}
from tabItem
where tabItem.docstatus < 2
and tabItem.disabled=0
and tabItem.has_variants=0
and (tabItem.end_of_life > %(today)s or ifnull(tabItem.end_of_life, '0000-00-00')='0000-00-00')
and ({scond} or tabItem.item_code IN (select parent from `tabItem Barcode` where barcode LIKE %(txt)s)
{description_cond})
{fcond} {mcond}
order by
if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
if(locate(%(_txt)s, item_name), locate(%(_txt)s, item_name), 99999),
idx desc,
name, item_name
limit %(start)s, %(page_len)s """.format(
columns=columns,
scond=searchfields,
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
mcond=get_match_cond(doctype).replace("%", "%%"),
description_cond=description_cond,
),
{
"today": nowdate(),
"txt": f"%{search_text}%",
"_txt": (search_terms[0] if search_terms else "").replace("%", ""),
"start": start,
"page_len": page_len,
**{f"txt_{i}": f"%{term}%" for i, term in enumerate(search_terms)},
},
as_dict=as_dict,
)
71 changes: 71 additions & 0 deletions av_tools/av_tools_hooks/test_item_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from unittest import TestCase
from unittest.mock import patch

from av_tools.av_tools_hooks import item_search


class TestItemSearch(TestCase):
def test_split_search_terms(self):
self.assertEqual(item_search.split_search_terms("blue chair"), ["blue", "chair"])
self.assertEqual(item_search.split_search_terms(" blue chair "), ["blue", "chair"])
self.assertEqual(item_search.split_search_terms(""), [])
self.assertEqual(item_search.split_search_terms(None), [])

def test_route_item_query(self):
self.assertEqual(
item_search.route_item_query("Item", item_search.ERP_ITEM_QUERY),
item_search.AV_TOOLS_ITEM_QUERY,
)
self.assertEqual(item_search.route_item_query("Item", None), item_search.AV_TOOLS_ITEM_QUERY)
self.assertEqual(
item_search.route_item_query("Customer", "frappe.desk.search.search_widget"),
"frappe.desk.search.search_widget",
)

def test_item_query_builds_multi_term_conditions(self):
class Meta:
def get_search_fields(self):
return ["item_name", "description"]

captured = {}

class FakeDB:
def exists(self, doctype, name):
return True

def count(self, doctype, cache=True):
return 10

def sql(self, query, values, as_dict=False):
captured["query"] = query
captured["values"] = values
captured["as_dict"] = as_dict
return []

with patch.object(item_search.frappe, "get_meta", return_value=Meta()), patch.object(
item_search.frappe, "db", FakeDB()
), patch.object(item_search, "get_filters_cond", return_value=""), patch.object(
item_search, "get_match_cond", return_value=""
), patch.object(
item_search,
"nowdate",
return_value="2026-05-13",
):
item_search.item_query.__wrapped__(
doctype="Item",
txt="blue chair",
searchfield="name",
start=0,
page_len=20,
filters={},
)

self.assertIn("item_name like %(txt_0)s and item_name like %(txt_1)s", captured["query"])
self.assertIn(
"tabItem.description like %(txt_0)s and tabItem.description like %(txt_1)s",
captured["query"],
)
self.assertEqual(captured["values"]["txt"], "%blue%chair%")
self.assertEqual(captured["values"]["_txt"], "blue")
self.assertEqual(captured["values"]["txt_0"], "%blue%")
self.assertEqual(captured["values"]["txt_1"], "%chair%")
2 changes: 2 additions & 0 deletions av_tools/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@
# ------------------------------
#
override_whitelisted_methods = {
"frappe.desk.search.search_link": "av_tools.av_tools_hooks.item_search.search_link",
"frappe.desk.search.search_widget": "av_tools.av_tools_hooks.item_search.search_widget",
"frappe.desk.query_report.get_script": "av_tools.av_tools_hooks.query_report.get_script",
"erpnext.buying.doctype.purchase_order.purchase_order.update_status": "av_tools.av_tools_hooks.generic_erp_behavior_overrides.update_purchase_order_status",
"erpnext.buying.doctype.purchase_order.purchase_order.close_or_unclose_purchase_orders": "av_tools.av_tools_hooks.generic_erp_behavior_overrides.close_or_unclose_purchase_orders",
Expand Down
Loading