Skip to content
Open
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
17 changes: 7 additions & 10 deletions POS/src/components/sale/CreateCustomerDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
93 changes: 77 additions & 16 deletions pos_next/api/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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(
{
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
102 changes: 102 additions & 0 deletions pos_next/api/test_customers.py
Original file line number Diff line number Diff line change
@@ -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")
Loading