diff --git a/POS/src/components/sale/CreateCustomerDialog.vue b/POS/src/components/sale/CreateCustomerDialog.vue index 824584c2..fab7832e 100644 --- a/POS/src/components/sale/CreateCustomerDialog.vue +++ b/POS/src/components/sale/CreateCustomerDialog.vue @@ -332,17 +332,14 @@ const updateTerritoryFromCountry = () => { // ============================================================================= const createCustomerResource = createResource({ - url: "frappe.client.insert", + url: "pos_next.api.customers.create_customer", makeParams: () => ({ - doc: { - doctype: "Customer", - customer_name: customerData.value.customer_name, - customer_type: "Individual", - customer_group: customerData.value.customer_group || __("Individual"), - territory: customerData.value.territory || __("All Territories"), - mobile_no: customerData.value.mobile_no || "", - email_id: customerData.value.email_id || "", - }, + customer_name: customerData.value.customer_name, + mobile_no: customerData.value.mobile_no || "", + email_id: customerData.value.email_id || "", + customer_group: customerData.value.customer_group || __("Individual"), + territory: customerData.value.territory || __("All Territories"), + pos_profile: props.posProfile, }), onSuccess: (data) => { showSuccess(__("Customer {0} created successfully", [data.customer_name])) diff --git a/pos_next/api/customers.py b/pos_next/api/customers.py index e28bea33..70209123 100644 --- a/pos_next/api/customers.py +++ b/pos_next/api/customers.py @@ -28,6 +28,7 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non ) filters = {} + or_filters = [] # Filter by POS Profile customer group if specified if pos_profile: @@ -45,10 +46,21 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non # Full fetch: only active customers filters["disabled"] = 0 + search_term = (search_term or "").strip() + if search_term: + like_term = f"%{search_term}%" + or_filters = [ + ["Customer", "name", "like", like_term], + ["Customer", "customer_name", "like", like_term], + ["Customer", "mobile_no", "like", like_term], + ["Customer", "email_id", "like", like_term], + ] + customer_limit = limit if limit not in (None, 0) else frappe.db.count("Customer", filters) result = frappe.get_all( "Customer", filters=filters, + or_filters=or_filters or None, fields=["name", "customer_name", "mobile_no", "email_id", "disabled"], limit=customer_limit, order_by="customer_name asc", @@ -62,7 +74,15 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non @frappe.whitelist() -def create_customer(customer_name, mobile_no=None, email_id=None, customer_group="Individual", territory="All Territories", company=None): +def create_customer( + customer_name, + mobile_no=None, + email_id=None, + customer_group="Individual", + territory="All Territories", + company=None, + pos_profile=None, +): """ Create a new customer from POS. @@ -73,6 +93,7 @@ def create_customer(customer_name, mobile_no=None, email_id=None, customer_group customer_group (str): Customer group (default: Individual) territory (str): Territory (default: All Territories) company (str): Company (optional, used to auto-assign loyalty program) + pos_profile (str): POS Profile (optional, preferred for context-aware loyalty assignment) Returns: dict: Created customer document @@ -84,10 +105,10 @@ def create_customer(customer_name, mobile_no=None, email_id=None, customer_group if not customer_name: frappe.throw(_("Customer name is required")) - # Auto-assign loyalty program based on company - loyalty_program = None - if company: - loyalty_program = get_default_loyalty_program(company) + loyalty_program = get_default_loyalty_program_from_settings( + company=company, + pos_profile=pos_profile, + ) customer = frappe.get_doc( { @@ -102,7 +123,13 @@ def create_customer(customer_name, mobile_no=None, email_id=None, customer_group } ) - customer.insert() + frappe.flags.pos_next_customer_company = company + frappe.flags.pos_next_customer_pos_profile = pos_profile + try: + customer.insert() + finally: + frappe.flags.pos_next_customer_company = None + frappe.flags.pos_next_customer_pos_profile = None return customer.as_dict() @@ -154,8 +181,11 @@ def auto_assign_loyalty_program(doc, method=None): if doc.loyalty_program: return - # Get loyalty program from POS Settings - loyalty_program = get_default_loyalty_program_from_settings() + company, pos_profile = _get_customer_assignment_context() + loyalty_program = get_default_loyalty_program_from_settings( + company=company, + pos_profile=pos_profile, + ) if loyalty_program: # Use db_set to avoid triggering validate hooks again @@ -165,24 +195,55 @@ def auto_assign_loyalty_program(doc, method=None): ) -def get_default_loyalty_program_from_settings(): +def _get_customer_assignment_context(): + """Get company/profile context for customer auto-assignment from the current request.""" + company = getattr(frappe.flags, "pos_next_customer_company", None) + pos_profile = getattr(frappe.flags, "pos_next_customer_pos_profile", None) + + form_dict = getattr(frappe.local, "form_dict", None) + if form_dict: + company = company or form_dict.get("company") + pos_profile = pos_profile or form_dict.get("pos_profile") + + return company, pos_profile + + +def get_default_loyalty_program_from_settings(company=None, pos_profile=None): """ - Get the default loyalty program from POS Settings. - Checks all enabled POS Settings and returns the first configured loyalty program. + Get the default loyalty program from POS Settings using explicit context. + Returns a program only when the company/profile context is clear enough to avoid + assigning the wrong loyalty program. Returns: str: Loyalty program name or None if not configured """ - # Find POS Settings with default_loyalty_program set + if pos_profile: + pos_settings = frappe.db.get_value( + "POS Settings", + {"enabled": 1, "pos_profile": pos_profile}, + "default_loyalty_program", + ) + return pos_settings or None + + if not company: + return None + pos_settings = frappe.get_all( "POS Settings", filters={"enabled": 1, "default_loyalty_program": ["is", "set"]}, - fields=["default_loyalty_program"], - limit=1 + fields=["pos_profile", "default_loyalty_program"], + order_by="modified desc", ) - if pos_settings and pos_settings[0].get("default_loyalty_program"): - return pos_settings[0].default_loyalty_program + company_programs = [] + for row in pos_settings: + profile_company = frappe.get_cached_value("POS Profile", row.pos_profile, "company") + if profile_company == company: + company_programs.append(row.default_loyalty_program) + + unique_programs = list(dict.fromkeys(program for program in company_programs if program)) + if len(unique_programs) == 1: + return unique_programs[0] return None diff --git a/pos_next/api/test_customers.py b/pos_next/api/test_customers.py new file mode 100644 index 00000000..fe955b77 --- /dev/null +++ b/pos_next/api/test_customers.py @@ -0,0 +1,102 @@ +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +import unittest +from unittest.mock import Mock, patch + +from pos_next.api.customers import ( + _get_customer_assignment_context, + create_customer, + get_customers, + get_default_loyalty_program_from_settings, +) + + +class TestCustomersAPI(unittest.TestCase): + @patch("pos_next.api.customers.frappe.logger") + @patch("pos_next.api.customers.frappe.get_all") + @patch("pos_next.api.customers.frappe.db") + def test_get_customers_applies_search_term_filters(self, mock_db, mock_get_all, mock_logger): + mock_logger.return_value = Mock() + mock_get_all.return_value = [] + + get_customers(search_term="john", limit=10) + + mock_get_all.assert_called_once() + kwargs = mock_get_all.call_args.kwargs + self.assertEqual(kwargs["filters"], {"disabled": 0}) + self.assertEqual( + kwargs["or_filters"], + [ + ["Customer", "name", "like", "%john%"], + ["Customer", "customer_name", "like", "%john%"], + ["Customer", "mobile_no", "like", "%john%"], + ["Customer", "email_id", "like", "%john%"], + ], + ) + + @patch("pos_next.api.customers.frappe.db") + def test_get_default_loyalty_program_from_settings_uses_explicit_pos_profile(self, mock_db): + mock_db.get_value.return_value = "LOYALTY-A" + + result = get_default_loyalty_program_from_settings(pos_profile="POS-A") + + self.assertEqual(result, "LOYALTY-A") + mock_db.get_value.assert_called_once_with( + "POS Settings", + {"enabled": 1, "pos_profile": "POS-A"}, + "default_loyalty_program", + ) + + @patch("pos_next.api.customers.frappe.get_cached_value") + @patch("pos_next.api.customers.frappe.get_all") + def test_get_default_loyalty_program_from_settings_skips_ambiguous_company_context( + self, + mock_get_all, + mock_get_cached_value, + ): + mock_get_all.return_value = [ + Mock(pos_profile="POS-1", default_loyalty_program="LOYALTY-A"), + Mock(pos_profile="POS-2", default_loyalty_program="LOYALTY-B"), + ] + mock_get_cached_value.side_effect = ["Company A", "Company A"] + + result = get_default_loyalty_program_from_settings(company="Company A") + + self.assertIsNone(result) + + @patch("pos_next.api.customers.frappe.local", new=Mock(form_dict={"company": "Company A", "pos_profile": "POS-A"})) + @patch("pos_next.api.customers.frappe.flags", new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None)) + def test_get_customer_assignment_context_uses_request_context(self): + company, pos_profile = _get_customer_assignment_context() + + self.assertEqual(company, "Company A") + self.assertEqual(pos_profile, "POS-A") + + @patch("pos_next.api.customers.frappe.flags", new=Mock(pos_next_customer_company=None, pos_next_customer_pos_profile=None)) + @patch("pos_next.api.customers.frappe.get_doc") + @patch("pos_next.api.customers.get_default_loyalty_program_from_settings") + @patch("pos_next.api.customers.frappe.has_permission") + def test_create_customer_uses_pos_profile_for_loyalty_assignment( + self, + mock_has_permission, + mock_get_loyalty, + mock_get_doc, + ): + mock_has_permission.return_value = True + mock_get_loyalty.return_value = "LOYALTY-A" + + customer_doc = Mock() + customer_doc.as_dict.return_value = {"name": "CUST-0001", "loyalty_program": "LOYALTY-A"} + mock_get_doc.return_value = customer_doc + + result = create_customer( + customer_name="John Doe", + customer_group="Individual", + territory="All Territories", + pos_profile="POS-A", + ) + + mock_get_loyalty.assert_called_once_with(company=None, pos_profile="POS-A") + customer_doc.insert.assert_called_once_with() + self.assertEqual(result["loyalty_program"], "LOYALTY-A")