diff --git a/docs/quick-sales-customer-field-behavior.md b/docs/quick-sales-customer-field-behavior.md new file mode 100644 index 00000000..f6dbdd4e --- /dev/null +++ b/docs/quick-sales-customer-field-behavior.md @@ -0,0 +1,55 @@ +# رفتار فیلد مشتری در فروش سریع + +این سند رفتار جست‌وجو، انتخاب و ثبت سریع مشتری در صفحهٔ فروش سریع را تعریف می‌کند. ثبت سریع با `enableQuickCreateOnSubmit` فقط برای نمونه‌های این فیلد در فروش سریع فعال است؛ سایر مصرف‌کنندگان `CustomerComboboxWidget` بدون درخواست صریح مشتری جدید ایجاد نمی‌کنند. + +## رفتار Enter + +ترتیب تصمیم‌گیری پس از زدن Enter به شکل زیر است: + +1. اگر نتایج متن فعلی هنوز بارگذاری نشده‌اند، ابتدا جست‌وجو کامل می‌شود. +2. اگر دقیقاً یک نتیجه وجود داشته باشد، همان مشتری انتخاب می‌شود. +3. اگر چند نتیجه وجود داشته باشد و کاربر با جهت‌نما ردیفی را مشخص کرده باشد، همان ردیف انتخاب می‌شود. +4. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، API تطبیق دقیق و نتایج مشابه را بررسی می‌کند؛ نتیجه‌های مشابه در همان منوی شناور می‌مانند تا کاربر با جهت‌نما و Enter یا موس انتخاب کند. +5. اگر نتیجه‌ای وجود نداشته باشد، ورودی برای یافتن یا ساختن سریع مشتری به API ارسال می‌شود. + +پاسخ جست‌وجوی قدیمی اجازه ندارد نتایج متن جدید را جایگزین کند. اولین ردیف نیز صرفاً به‌دلیل قرارگرفتن در ابتدای فهرست، انتخاب‌شده محسوب نمی‌شود. + +## قالب‌های ورودی ثبت سریع + +ورودی می‌تواند شامل نام، موبایل یا هر دو باشد. ترتیب نام و موبایل مهم نیست و تمام بخش نام به‌صورت یکپارچه در `alias_name` قرار می‌گیرد. + +| ورودی | نام مستعار | موبایل | +|---|---|---| +| `علی رضایی` | `علی رضایی` | خالی | +| `09121234567` | `09121234567` | `09121234567` | +| `09121234567 سید محمد مهدی رضوی` | `سید محمد مهدی رضوی` | `09121234567` | +| `زهرا سادات موسوی +989121234567` | `زهرا سادات موسوی` | `09121234567` | +| `۰۹۱۲۱۲۳۴۵۶۷ کاظمی` | `کاظمی` | `09121234567` | + +اعداد فارسی و عربی به انگلیسی تبدیل می‌شوند. قالب‌های `09xxxxxxxxx`، `9xxxxxxxxx`، `+989xxxxxxxxx`، `989xxxxxxxxx` و `00989xxxxxxxxx` پشتیبانی می‌شوند. فاصله و خط تیره داخل شماره نیز پذیرفته می‌شود. وجود بیش از یک موبایل معتبر، ورودی را مبهم می‌کند و ثبت خودکار انجام نمی‌شود. + +در ورودی فقط موبایل، به‌دلیل الزامی‌بودن نام مستعار شخص، موبایل استانداردشده به‌عنوان نام مستعار نیز ذخیره می‌شود. + +## جلوگیری از ثبت تکراری + +API پیش از ایجاد شخص، تمام اشخاص همان کسب‌وکار را دوباره بررسی می‌کند: + +- موبایل پس از استانداردسازی با فیلدهای موبایل، موبایل دوم، موبایل سوم و تلفن مقایسه می‌شود. +- نام با یکسان‌سازی فاصله‌ها، نیم‌فاصله و حروف عربی/فارسی مقایسه می‌شود. +- نام مستعار، نام، نام خانوادگی، نام شرکت و ترکیب نام و نام خانوادگی در بررسی تشابه شرکت دارند. +- اگر یک شخص مشابه پیدا شود، همان شخص انتخاب می‌شود و رکورد جدید ساخته نمی‌شود. +- اگر چند شخص مشابه پیدا شوند، فهرست آن‌ها نمایش داده می‌شود تا کاربر انتخاب کند. +- تطبیق موبایل دقیق بر تطبیق نام و تطبیق نام دقیق بر نام‌های جزئی اولویت دارد. +- در PostgreSQL، بررسی و ایجاد سریع برای هر کسب‌وکار با قفل تراکنشی سریالی می‌شود تا دو درخواست هم‌زمان رکورد تکراری نسازند. + +شخصی که واقعاً جدید باشد با نوع «مشتری» ساخته می‌شود و بدون بازشدن فرم شخص، برای فاکتور جاری انتخاب می‌شود. + +در پیش‌نمایش وب محلی، اگر `API_BASE_URL` صریحاً تنظیم نشده باشد، درخواست‌ها به پورت `8000` همان میزبان ارسال می‌شوند؛ برای نمونه، رابط `192.168.50.101:8080` از API آدرس `192.168.50.101:8000` استفاده می‌کند. این fallback مانع ارسال اشتباه درخواست‌ها به وب‌سرور Flutter روی پورت ۸۰۸۰ و دریافت خطای `405 Method Not Allowed` می‌شود. در محیط‌های غیرمحلی، همان origin و reverse proxy برنامه استفاده می‌شود. + +## مسیرهای پیاده‌سازی + +- رابط و رفتار صفحه‌کلید: `hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart` +- تجزیهٔ نام و موبایل: `hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart` +- فراخوانی API: `hesabixUI/hesabix_ui/lib/services/customer_service.dart` +- endpoint: `POST /api/v1/customers/quick-resolve` +- منطق تطبیق و ایجاد: `hesabixAPI/app/services/customer_quick_entry_service.py` diff --git a/hesabixAPI/adapters/api/v1/customers.py b/hesabixAPI/adapters/api/v1/customers.py index 76b75e8d..acd95619 100755 --- a/hesabixAPI/adapters/api/v1/customers.py +++ b/hesabixAPI/adapters/api/v1/customers.py @@ -3,11 +3,13 @@ from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional, List +from adapters.db.models.person import Person from adapters.db.session import get_db from app.core.responses import success_response, format_datetime_fields from app.core.auth_dependency import get_current_user, AuthContext from app.core.permissions import require_business_access_dep from app.services.person_service import search_persons, count_persons, get_person_by_id +from app.services.customer_quick_entry_service import resolve_or_create_quick_customer router = APIRouter(prefix="/customers", tags=["اشخاص و مشتریان"]) @@ -38,6 +40,63 @@ class CustomerSearchResponse(BaseModel): has_more: bool +class CustomerQuickResolveRequest(BaseModel): + business_id: int + alias_name: Optional[str] = None + mobile: Optional[str] = None + + +class CustomerQuickResolveResponse(BaseModel): + customers: List[CustomerResponse] + created: bool + + +def _customer_response(person: Person) -> CustomerResponse: + name_parts = [ + value + for value in (person.alias_name, person.first_name, person.last_name) + if value + ] + return CustomerResponse( + id=person.id, + name=" ".join(name_parts) if name_parts else "نامشخص", + code=str(person.code) if person.code else None, + phone=person.phone or person.mobile, + email=person.email, + address=person.address, + is_active=True, + created_at=person.created_at.isoformat() if person.created_at else None, + ) + + +@router.post( + "/quick-resolve", + summary="یافتن یا ایجاد سریع مشتری", + description=( + "مشتری موجود را با موبایل یا نام مشابه برمی‌گرداند و تنها در صورت " + "نبود نتیجه، مشتری جدید ایجاد می‌کند" + ), + response_model=CustomerQuickResolveResponse, +) +async def quick_resolve_customer( + request: Request, + payload: CustomerQuickResolveRequest, + ctx: AuthContext = Depends(get_current_user), + db: Session = Depends(get_db), + _: None = Depends(require_business_access_dep), +): + resolution = resolve_or_create_quick_customer( + db, + business_id=payload.business_id, + alias_name=payload.alias_name, + mobile=payload.mobile, + ) + return CustomerQuickResolveResponse( + customers=[_customer_response(person) for person in resolution.persons], + created=resolution.created, + ) + + @router.post("/search", summary="جست‌وجوی مشتری‌ها", description="جست‌وجو در لیست مشتری‌ها (اشخاص) با قابلیت فیلتر و صفحه‌بندی", @@ -98,30 +157,7 @@ async def search_customers( limit=search_request.limit ) - # تبدیل به فرمت مشتری - customers = [] - for person in persons: - # ساخت نام کامل - name_parts = [] - if person.alias_name: - name_parts.append(person.alias_name) - if person.first_name: - name_parts.append(person.first_name) - if person.last_name: - name_parts.append(person.last_name) - full_name = " ".join(name_parts) if name_parts else person.alias_name or "نامشخص" - - customer = CustomerResponse( - id=person.id, - name=full_name, - code=str(person.code) if person.code else None, - phone=person.phone or person.mobile, - email=person.email, - address=person.address, - is_active=True, # اشخاص همیشه فعال در نظر گرفته می‌شوند - created_at=person.created_at.isoformat() if person.created_at else None - ) - customers.append(customer) + customers = [_customer_response(person) for person in persons] # محاسبه تعداد کل total_count = count_persons( diff --git a/hesabixAPI/adapters/api/v1/kardex.py b/hesabixAPI/adapters/api/v1/kardex.py index 57fc1aae..557961c5 100755 --- a/hesabixAPI/adapters/api/v1/kardex.py +++ b/hesabixAPI/adapters/api/v1/kardex.py @@ -846,7 +846,7 @@ async def export_kardex_pdf_endpoint(

{'گزارش کاردکس' if is_fa else 'Kardex Report'}

{( "
" + - "".join([f"{escape(str(f.get('label')))}: {escape(str(f.get('value')))}" for f in (filters_summary or [])]) + + "".join([f'''{escape(str(f.get('label')))}: {escape(str(f.get('value')))}''' for f in (filters_summary or [])]) + "
" ) if filters_summary else ""} diff --git a/hesabixAPI/app/services/customer_quick_entry_service.py b/hesabixAPI/app/services/customer_quick_entry_service.py new file mode 100644 index 00000000..8a65426d --- /dev/null +++ b/hesabixAPI/app/services/customer_quick_entry_service.py @@ -0,0 +1,206 @@ +"""Resolve or create a customer entered directly in a customer search field.""" +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import Iterable + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from adapters.api.v1.schema_models.person import ( + PersonCreateRequest, + PersonType as PersonRequestType, +) +from adapters.db.models.person import Person +from app.core.responses import ApiError +from app.services.person_service import create_person +from app.services.telephony.phone_normalizer import normalize_iran_phone, numbers_match + + +_NAME_TRANSLATION = str.maketrans({"ي": "ی", "ى": "ی", "ك": "ک", "ۀ": "ه"}) + + +@dataclass(frozen=True) +class QuickCustomerResolution: + persons: list[Person] + created: bool + + +def clean_customer_alias(value: str | None) -> str | None: + if value is None: + return None + cleaned = value.translate(_NAME_TRANSLATION).replace("‌", " ") + cleaned = " ".join(cleaned.split()) + return cleaned or None + + +def normalize_customer_alias(value: str | None) -> str: + return (clean_customer_alias(value) or "").casefold() + + +def normalize_customer_mobile(value: str | None) -> str | None: + if value is None or not str(value).strip(): + return None + canonical = normalize_iran_phone(str(value)).get("canonical") + if canonical and re.fullmatch(r"09\d{9}", canonical): + return canonical + return None + + +def _person_alias_candidates(person: Person) -> set[str]: + raw_values = { + person.alias_name, + person.first_name, + person.last_name, + person.company_name, + " ".join( + value + for value in (person.alias_name, person.first_name, person.last_name) + if value + ), + " ".join( + value for value in (person.first_name, person.last_name) if value + ), + } + return { + normalized + for normalized in (normalize_customer_alias(value) for value in raw_values) + if normalized + } + + +def person_matches_quick_customer_entry( + person: Person, + *, + alias_name: str | None, + mobile: str | None, +) -> bool: + if mobile: + stored_numbers = ( + person.mobile, + person.mobile_2, + person.mobile_3, + person.phone, + ) + if any(numbers_match(mobile, stored, min_len=10) for stored in stored_numbers): + return True + + normalized_alias = normalize_customer_alias(alias_name) + if normalized_alias: + for candidate in _person_alias_candidates(person): + if normalized_alias in candidate or candidate in normalized_alias: + return True + + return False + + +def _person_matches_mobile(person: Person, mobile: str) -> bool: + stored_numbers = ( + person.mobile, + person.mobile_2, + person.mobile_3, + person.phone, + ) + return any(numbers_match(mobile, stored, min_len=10) for stored in stored_numbers) + + +def find_quick_customer_matches( + persons: Iterable[Person], + *, + alias_name: str | None, + mobile: str | None, + limit: int = 20, +) -> list[Person]: + candidates = list(persons) + + # موبایل شناسه قوی‌تری از نام است. اگر پیدا شود، شباهت نام نباید اشخاص + # دیگری را وارد نتیجه کند و جلوی انتخاب خودکار مشتری موجود را بگیرد. + if mobile: + mobile_matches = [ + person for person in candidates if _person_matches_mobile(person, mobile) + ] + if mobile_matches: + return mobile_matches[:limit] + + normalized_alias = normalize_customer_alias(alias_name) + if not normalized_alias: + return [] + + exact_matches: list[Person] = [] + similar_matches: list[Person] = [] + for person in candidates: + aliases = _person_alias_candidates(person) + if normalized_alias in aliases: + exact_matches.append(person) + elif any( + normalized_alias in candidate or candidate in normalized_alias + for candidate in aliases + ): + similar_matches.append(person) + + # نام دقیق نیز قبل از نتایج جزئی انتخاب می‌شود. چند رکورد دقیق همچنان برای + # انتخاب صریح کاربر برگردانده می‌شوند و رکورد تازه ساخته نمی‌شود. + return (exact_matches or similar_matches)[:limit] + + +def resolve_or_create_quick_customer( + db: Session, + *, + business_id: int, + alias_name: str | None, + mobile: str | None, +) -> QuickCustomerResolution: + cleaned_alias = clean_customer_alias(alias_name) + normalized_mobile = normalize_customer_mobile(mobile) + if mobile and not normalized_mobile: + raise ApiError("INVALID_CUSTOMER_MOBILE", "شماره موبایل معتبر نیست") + if not cleaned_alias and not normalized_mobile: + raise ApiError( + "CUSTOMER_QUICK_ENTRY_REQUIRED", + "نام مشتری یا شماره موبایل را وارد کنید", + ) + + # درخواست‌های هم‌زمان یک کسب‌وکار نباید بین بررسی تکراری و ایجاد فاصله بیفتند. + bind = db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + db.execute( + text("SELECT pg_advisory_xact_lock(:namespace, :business_id)"), + {"namespace": 1129665364, "business_id": int(business_id)}, + ) + + persons = ( + db.query(Person) + .filter(Person.business_id == int(business_id)) + .order_by(Person.alias_name, Person.id) + .all() + ) + matches = find_quick_customer_matches( + persons, + alias_name=cleaned_alias, + mobile=normalized_mobile, + ) + if matches: + return QuickCustomerResolution(persons=matches, created=False) + + stored_alias = cleaned_alias or normalized_mobile + assert stored_alias is not None + result = create_person( + db, + int(business_id), + PersonCreateRequest( + alias_name=stored_alias, + mobile=normalized_mobile, + person_types=[PersonRequestType.CUSTOMER], + ), + ) + person_id = int(result["data"]["id"]) + created = ( + db.query(Person) + .filter( + Person.business_id == int(business_id), + Person.id == person_id, + ) + .one() + ) + return QuickCustomerResolution(persons=[created], created=True) diff --git a/hesabixAPI/tests/test_customer_quick_entry_service.py b/hesabixAPI/tests/test_customer_quick_entry_service.py new file mode 100644 index 00000000..a3f4929d --- /dev/null +++ b/hesabixAPI/tests/test_customer_quick_entry_service.py @@ -0,0 +1,116 @@ +from types import SimpleNamespace + +from app.services.customer_quick_entry_service import ( + clean_customer_alias, + find_quick_customer_matches, + normalize_customer_alias, + normalize_customer_mobile, +) + + +def _person( + person_id: int, + alias_name: str, + *, + mobile: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +): + return SimpleNamespace( + id=person_id, + alias_name=alias_name, + first_name=first_name, + last_name=last_name, + company_name=None, + mobile=mobile, + mobile_2=None, + mobile_3=None, + phone=None, + ) + + +def test_normalizes_customer_alias_and_mobile_variants(): + assert clean_customer_alias(" علي رضايي ") == "علی رضایی" + assert normalize_customer_alias("علي‌ رضايي") == "علی رضایی" + assert normalize_customer_mobile("+98 912 123 4567") == "09121234567" + assert normalize_customer_mobile("۰۹۱۲۱۲۳۴۵۶۷") == "09121234567" + + +def test_finds_existing_person_by_mobile_before_creation(): + persons = [_person(1, "علی رضایی", mobile="+989121234567")] + matches = find_quick_customer_matches( + persons, + alias_name=None, + mobile="09121234567", + ) + assert [person.id for person in matches] == [1] + + +def test_finds_similar_normalized_alias(): + persons = [ + _person(1, "علی رضایی"), + _person(2, "زهرا موسوی"), + ] + matches = find_quick_customer_matches( + persons, + alias_name="علي", + mobile=None, + ) + assert [person.id for person in matches] == [1] + + +def test_finds_name_split_between_person_fields(): + persons = [ + _person( + 1, + "محمدرضا", + first_name="محمدرضا", + last_name="حسینی", + ) + ] + matches = find_quick_customer_matches( + persons, + alias_name="محمدرضا حسینی", + mobile=None, + ) + assert [person.id for person in matches] == [1] + + +def test_mobile_match_takes_priority_over_similar_names(): + persons = [ + _person(1, "علی رضایی", mobile="09121234567"), + _person(2, "علی رضایی فروشگاه"), + ] + matches = find_quick_customer_matches( + persons, + alias_name="علی رضایی", + mobile="+989121234567", + ) + assert [person.id for person in matches] == [1] + + +def test_exact_name_takes_priority_over_partial_names(): + persons = [ + _person(1, "علی رضایی فروشگاه"), + _person(2, "علی رضایی"), + ] + matches = find_quick_customer_matches( + persons, + alias_name="علی رضایی", + mobile=None, + ) + assert [person.id for person in matches] == [2] + + +def test_returns_all_similar_names_when_there_is_no_exact_match(): + persons = [ + _person(1, "علی رضایی"), + _person(2, "علی احمدی"), + _person(3, "زهرا موسوی"), + ] + matches = find_quick_customer_matches( + persons, + alias_name="علی", + mobile=None, + ) + assert [person.id for person in matches] == [1, 2] diff --git a/hesabixUI/hesabix_ui/lib/config/app_config.dart b/hesabixUI/hesabix_ui/lib/config/app_config.dart index 728a4597..1a4e3cd8 100755 --- a/hesabixUI/hesabix_ui/lib/config/app_config.dart +++ b/hesabixUI/hesabix_ui/lib/config/app_config.dart @@ -15,14 +15,38 @@ class AppConfig { /// روی reverse proxy مسیرهای `/api/*` و `/ws/*` به بک‌اند پاس داده شوند). /// - در غیر وب، پیش‌فرض `http://localhost:8000` است. static String get apiBaseUrl { - final v = _envApiBaseUrl.trim(); - if (v.isNotEmpty) return v; + return resolveApiBaseUrl( + configuredValue: _envApiBaseUrl, + isWebBuild: kIsWeb, + currentUri: Uri.base, + ); + } - if (kIsWeb) { - final u = Uri.base; + static String resolveApiBaseUrl({ + required String configuredValue, + required bool isWebBuild, + required Uri currentUri, + }) { + final value = configuredValue.trim(); + if (value.isNotEmpty) return value; + + if (isWebBuild) { + final host = currentUri.host.toLowerCase(); + final isLocalPreview = + host == 'localhost' || host == '127.0.0.1' || currentUri.port == 8080; + if (isLocalPreview) { + // Flutter web-server روی 8080 فقط فایل استاتیک سرو می‌کند. فرستادن + // درخواست‌های API به همان origin خطا می‌دهد. این تشخیص شامل IP شبکه + // نیز هست تا پیش‌نمایش روی دستگاه دیگری API همان میزبان را ببیند. + return Uri( + scheme: currentUri.scheme, + host: currentUri.host, + port: 8000, + ).origin; + } // مثال: http://localhost:8080 یا https://arc.hesabix.ir // در این حالت انتظار داریم reverse proxy مسیرهای api/ws را route کند. - return u.origin; + return currentUri.origin; } return 'http://localhost:8000'; @@ -53,5 +77,3 @@ class AppConfig { return '$b/login?reset_token=${Uri.encodeComponent(token)}'; } } - - diff --git a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart index 59fc99cb..bed9db80 100755 --- a/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/business/quick_sales_page.dart @@ -3588,6 +3588,7 @@ class _QuickSalesPageState extends State businessId: widget.businessId, authStore: widget.authStore, isRequired: false, + enableQuickCreateOnSubmit: true, label: 'مشتری', hintText: 'مشتری ناشناس', ), @@ -4471,6 +4472,7 @@ class _QuickSalesPageState extends State businessId: widget.businessId, authStore: widget.authStore, isRequired: false, + enableQuickCreateOnSubmit: true, label: 'مشتری', hintText: 'مشتری ناشناس', ), @@ -4874,6 +4876,7 @@ class _QuickSalesPageState extends State businessId: widget.businessId, authStore: widget.authStore, isRequired: false, + enableQuickCreateOnSubmit: true, dense: true, label: 'مشتری', hintText: 'مشتری ناشناس', diff --git a/hesabixUI/hesabix_ui/lib/services/customer_service.dart b/hesabixUI/hesabix_ui/lib/services/customer_service.dart index 9b8a8fbc..0df95784 100755 --- a/hesabixUI/hesabix_ui/lib/services/customer_service.dart +++ b/hesabixUI/hesabix_ui/lib/services/customer_service.dart @@ -32,7 +32,7 @@ class CustomerService { if (response.statusCode == 200) { final data = response.data as Map; - + // تبدیل لیست مشتری‌ها final customersJson = data['customers'] as List; final customers = customersJson @@ -54,6 +54,30 @@ class CustomerService { } } + /// مشتری موجود را برمی‌گرداند یا پس از بررسی تکراری، مشتری جدید می‌سازد. + Future> quickResolveCustomer({ + required int businessId, + String? aliasName, + String? mobile, + }) async { + final response = await _apiClient.post( + '/api/v1/customers/quick-resolve', + data: { + 'business_id': businessId, + if (aliasName != null && aliasName.trim().isNotEmpty) + 'alias_name': aliasName.trim(), + if (mobile != null && mobile.trim().isNotEmpty) 'mobile': mobile.trim(), + }, + ); + final data = Map.from(response.data as Map); + final customers = (data['customers'] as List? ?? const []) + .map( + (json) => Customer.fromJson(Map.from(json as Map)), + ) + .toList(); + return {'customers': customers, 'created': data['created'] == true}; + } + /// دریافت اطلاعات یک مشتری خاص Future getCustomerById({ required int businessId, @@ -87,9 +111,7 @@ class CustomerService { final response = await _apiClient.get( '/api/v1/customers/access-check', query: {'business_id': businessId}, - options: Options( - headers: {'Authorization': 'Bearer $authToken'}, - ), + options: Options(headers: {'Authorization': 'Bearer $authToken'}), ); return response.statusCode == 200; diff --git a/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart b/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart new file mode 100644 index 00000000..79b42319 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart @@ -0,0 +1,104 @@ +import 'number_normalizer.dart'; + +class CustomerQuickEntry { + final String? aliasName; + final String? mobile; + + const CustomerQuickEntry({required this.aliasName, required this.mobile}); +} + +enum CustomerSearchSubmitAction { + selectSuggestion, + search, + quickCreate, + waitForSelection, +} + +String _collapseCustomerName(String value) => value + .replaceAll('ي', 'ی') + .replaceAll('ى', 'ی') + .replaceAll('ك', 'ک') + .replaceAll('‌', ' ') + .trim() + .split(RegExp(r'\s+')) + .where((part) => part.isNotEmpty) + .join(' '); + +String? _canonicalIranianMobile(String value) { + final digits = toEnglishDigits(value).replaceAll(RegExp(r'\D'), ''); + late final String canonical; + if (digits.startsWith('0098') && digits.length == 14) { + canonical = '0${digits.substring(4)}'; + } else if (digits.startsWith('98') && digits.length == 12) { + canonical = '0${digits.substring(2)}'; + } else if (digits.startsWith('09') && digits.length == 11) { + canonical = digits; + } else if (digits.startsWith('9') && digits.length == 10) { + canonical = '0$digits'; + } else { + return null; + } + return RegExp(r'^09\d{9}$').hasMatch(canonical) ? canonical : null; +} + +CustomerQuickEntry? parseCustomerQuickEntry(String rawInput) { + final input = toEnglishDigits(rawInput).trim(); + if (input.isEmpty) return null; + + final mobilePattern = RegExp( + r'(?:(?:\+98|0098|98)9|0?9)(?:[\s\-\(\)]?\d){9}', + ); + final matches = mobilePattern.allMatches(input).where((match) { + final before = match.start > 0 ? input[match.start - 1] : ''; + final after = match.end < input.length ? input[match.end] : ''; + return !RegExp(r'\d').hasMatch(before) && !RegExp(r'\d').hasMatch(after); + }).toList(); + + if (matches.length > 1) return null; + if (matches.isEmpty) { + final alias = _collapseCustomerName(input); + return alias.isEmpty + ? null + : CustomerQuickEntry(aliasName: alias, mobile: null); + } + + final rawMobile = matches.single.group(0)!; + final mobile = _canonicalIranianMobile(rawMobile); + if (mobile == null) return null; + final alias = _collapseCustomerName( + input.replaceRange(matches.single.start, matches.single.end, ' '), + ); + return CustomerQuickEntry( + aliasName: alias.isEmpty ? null : alias, + mobile: mobile, + ); +} + +CustomerSearchSubmitAction resolveCustomerSearchSubmitAction({ + required String input, + required String loadedQuery, + required int suggestionCount, + required bool hasMoreSuggestions, + required bool navigatedByKeyboard, + required bool isLoading, + required bool quickCreateEnabled, +}) { + final query = input.trim(); + if (query.isEmpty) return CustomerSearchSubmitAction.waitForSelection; + if (isLoading || loadedQuery.trim() != query) { + return CustomerSearchSubmitAction.search; + } + if (navigatedByKeyboard || (suggestionCount == 1 && !hasMoreSuggestions)) { + return CustomerSearchSubmitAction.selectSuggestion; + } + if (suggestionCount > 0) { + // در فروش سریع، API باید تشخیص دهد نتیجه دقیق است یا چند مشتری مشابه + // باید برای انتخاب صریح کاربر نمایش داده شوند. + return quickCreateEnabled + ? CustomerSearchSubmitAction.quickCreate + : CustomerSearchSubmitAction.waitForSelection; + } + return quickCreateEnabled + ? CustomerSearchSubmitAction.quickCreate + : CustomerSearchSubmitAction.waitForSelection; +} diff --git a/hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart b/hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart index 6552f547..50163fdc 100755 --- a/hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart +++ b/hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart @@ -5,6 +5,9 @@ import '../../models/customer_model.dart'; import '../../services/customer_service.dart'; import '../../core/auth_store.dart'; import '../../core/api_client.dart'; +import '../../utils/customer_quick_entry.dart'; +import '../../utils/error_extractor.dart'; +import '../../utils/snackbar_helper.dart'; import '../../widgets/person/person_form_dialog.dart'; import '../../widgets/person/person_financial_balance_banner.dart'; import 'invoice_form_layout.dart'; @@ -51,9 +54,11 @@ class CustomerComboboxWidget extends StatefulWidget { final bool isRequired; final String? label; final String? hintText; + /// مانده طرف حساب زیر نام داخل همان فیلد (شناسه مشتری همان شخص است) final bool showFinancialBalance; final bool dense; + final bool enableQuickCreateOnSubmit; const CustomerComboboxWidget({ super.key, @@ -66,6 +71,7 @@ class CustomerComboboxWidget extends StatefulWidget { this.hintText = 'انتخاب طرف حساب', this.showFinancialBalance = false, this.dense = false, + this.enableQuickCreateOnSubmit = false, }); @override @@ -85,15 +91,16 @@ class _CustomerComboboxWidgetState extends State { bool _hasMore = false; int _currentPage = 1; String _currentQuery = ''; - final ValueNotifier<_CustomerPickerState> _pickerStateNotifier = ValueNotifier<_CustomerPickerState>( - _CustomerPickerState( - customers: [], - isLoading: false, - isLoadingMore: false, - hasSearched: false, - hasMore: false, - ), - ); + final ValueNotifier<_CustomerPickerState> _pickerStateNotifier = + ValueNotifier<_CustomerPickerState>( + _CustomerPickerState( + customers: [], + isLoading: false, + isLoadingMore: false, + hasSearched: false, + hasMore: false, + ), + ); final FocusNode _fieldFocus = FocusNode(); final LayerLink _layerLink = LayerLink(); final ScrollController _overlayScrollController = ScrollController(); @@ -101,11 +108,20 @@ class _CustomerComboboxWidgetState extends State { int _highlightedIndex = -1; double _desktopFieldWidth = 0; bool _suppressFieldNotifications = false; + bool _navigatedByKeyboard = false; + bool _isEditingQuery = false; + bool _isQuickResolving = false; + bool _mobilePickerOpen = false; + int _searchGeneration = 0; + String _loadedQuery = ''; double _desktopOverlayHeight(_CustomerPickerState state) { if (state.isLoading && state.customers.isEmpty) return 120; if (!state.isLoading && state.customers.isEmpty) return 95; - final extraRow = (state.isLoadingMore || (state.isLoading && state.customers.isNotEmpty)) ? 1 : 0; + final extraRow = + (state.isLoadingMore || (state.isLoading && state.customers.isNotEmpty)) + ? 1 + : 0; final rows = state.customers.length + extraRow; const rowHeight = 62.0; final raw = (rows * rowHeight) + (state.isLoading ? 4 : 0); @@ -125,6 +141,7 @@ class _CustomerComboboxWidgetState extends State { void didUpdateWidget(covariant CustomerComboboxWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.selectedCustomer?.id != widget.selectedCustomer?.id) { + if (_fieldFocus.hasFocus && _isEditingQuery) return; _setFieldQuiet(widget.selectedCustomer?.name ?? ''); } } @@ -164,6 +181,10 @@ class _CustomerComboboxWidgetState extends State { Future.delayed(const Duration(milliseconds: 180), () { if (!mounted || _fieldFocus.hasFocus) return; _removeDesktopOverlay(); + if (_isEditingQuery) { + _isEditingQuery = false; + _setFieldQuiet(widget.selectedCustomer?.name ?? ''); + } }); } } @@ -185,6 +206,7 @@ class _CustomerComboboxWidgetState extends State { _desktopOverlayEntry?.remove(); _desktopOverlayEntry = null; _highlightedIndex = -1; + _navigatedByKeyboard = false; } void _onDesktopOverlayScroll() { @@ -236,13 +258,22 @@ class _CustomerComboboxWidgetState extends State { ); } - Widget _buildDesktopCustomersList(BuildContext context, _CustomerPickerState state) { + Widget _buildDesktopCustomersList( + BuildContext context, + _CustomerPickerState state, + ) { final cs = Theme.of(context).colorScheme; if (state.isLoading && state.customers.isEmpty) { - return const SizedBox(height: 120, child: Center(child: CircularProgressIndicator(strokeWidth: 2))); + return const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); } if (!state.isLoading && state.customers.isEmpty) { - return const SizedBox(height: 90, child: Center(child: Text('طرف حسابی یافت نشد'))); + return const SizedBox( + height: 90, + child: Center(child: Text('طرف حسابی یافت نشد')), + ); } return Column( mainAxisSize: MainAxisSize.min, @@ -251,11 +282,16 @@ class _CustomerComboboxWidgetState extends State { Flexible( child: ListView.builder( controller: _overlayScrollController, - itemCount: state.customers.length + - ((state.isLoadingMore || (state.isLoading && state.customers.isNotEmpty)) ? 1 : 0), + itemCount: + state.customers.length + + ((state.isLoadingMore || + (state.isLoading && state.customers.isNotEmpty)) + ? 1 + : 0), itemBuilder: (context, index) { if (index == state.customers.length && - (state.isLoadingMore || (state.isLoading && state.customers.isNotEmpty))) { + (state.isLoadingMore || + (state.isLoading && state.customers.isNotEmpty))) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center( @@ -270,13 +306,21 @@ class _CustomerComboboxWidgetState extends State { final customer = state.customers[index]; final selected = index == _highlightedIndex; return Material( - color: selected ? cs.primary.withValues(alpha: 0.10) : Colors.transparent, + color: selected + ? cs.primary.withValues(alpha: 0.10) + : Colors.transparent, child: InkWell( onTapDown: (_) => _selectCustomerFromOverlay(customer), child: ListTile( dense: true, - title: Text(customer.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: customer.code != null ? Text('کد: ${customer.code}') : null, + title: Text( + customer.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: customer.code != null + ? Text('کد: ${customer.code}') + : null, ), ), ); @@ -288,15 +332,23 @@ class _CustomerComboboxWidgetState extends State { } void _selectCustomerFromOverlay(Customer customer) { + _debounceTimer?.cancel(); + _searchGeneration++; + _isEditingQuery = false; + _navigatedByKeyboard = false; _setFieldQuiet(customer.name); widget.onCustomerChanged(customer); _removeDesktopOverlay(); _fieldFocus.unfocus(); + if (_mobilePickerOpen && mounted) { + Navigator.of(context).pop(); + } } void _moveHighlight(int delta) { final items = _pickerStateNotifier.value.customers; if (items.isEmpty) return; + _navigatedByKeyboard = true; var idx = _highlightedIndex; if (idx < 0 || idx >= items.length) { idx = delta > 0 ? 0 : items.length - 1; @@ -311,7 +363,9 @@ class _CustomerComboboxWidgetState extends State { void _selectHighlighted() { final items = _pickerStateNotifier.value.customers; if (items.isEmpty) return; - final idx = (_highlightedIndex >= 0 && _highlightedIndex < items.length) ? _highlightedIndex : 0; + final idx = (_highlightedIndex >= 0 && _highlightedIndex < items.length) + ? _highlightedIndex + : 0; _selectCustomerFromOverlay(items[idx]); } @@ -326,10 +380,6 @@ class _CustomerComboboxWidgetState extends State { _moveHighlight(-1); return KeyEventResult.handled; } - if (event.logicalKey == LogicalKeyboardKey.enter || event.logicalKey == LogicalKeyboardKey.numpadEnter) { - _selectHighlighted(); - return KeyEventResult.handled; - } if (event.logicalKey == LogicalKeyboardKey.escape) { _removeDesktopOverlay(); return KeyEventResult.handled; @@ -340,6 +390,8 @@ class _CustomerComboboxWidgetState extends State { Future _loadRecentCustomers() async { if (_hasLoadedRecent && !_isSearchMode) return; + final requestGeneration = ++_searchGeneration; + setState(() { _isLoading = true; }); @@ -350,6 +402,8 @@ class _CustomerComboboxWidgetState extends State { limit: 5, ); + if (!mounted || requestGeneration != _searchGeneration) return; + setState(() { _customers = result['customers'] as List; _isLoading = false; @@ -360,6 +414,7 @@ class _CustomerComboboxWidgetState extends State { _hasMore = false; // در حالت "اخیرها" صفحه‌بندی نداریم _currentPage = 1; _currentQuery = ''; + _loadedQuery = ''; }); // به‌روزرسانی ValueNotifier _pickerStateNotifier.value = _CustomerPickerState( @@ -369,9 +424,11 @@ class _CustomerComboboxWidgetState extends State { hasSearched: _hasSearched, hasMore: _hasMore, ); - _highlightedIndex = _customers.isEmpty ? -1 : 0; + _highlightedIndex = -1; + _navigatedByKeyboard = false; _desktopOverlayEntry?.markNeedsBuild(); } catch (e) { + if (!mounted || requestGeneration != _searchGeneration) return; setState(() { _isLoading = false; _hasLoadedRecent = true; @@ -385,21 +442,25 @@ class _CustomerComboboxWidgetState extends State { } void _onSearchChanged(String query) { - print('[CustomerCombobox] _onSearchChanged called with query: "$query"'); _debounceTimer?.cancel(); + _isEditingQuery = true; + _navigatedByKeyboard = false; + _highlightedIndex = -1; + // پاسخ جست‌وجوی قبلی نباید متن یا پیشنهادهای ورودی جدید را بازنویسی کند. + _searchGeneration++; _debounceTimer = Timer(const Duration(milliseconds: 500), () { - print('[CustomerCombobox] Debounce timer fired, calling _searchCustomers with: "${query.trim()}"'); - _searchCustomers(query.trim()); + unawaited(_searchCustomers(query.trim())); }); } - Future _searchCustomers(String query) async { - print('[CustomerCombobox] _searchCustomers called with query: "$query"'); + Future _searchCustomers(String query) async { if (query.isEmpty) { await _loadRecentCustomers(); - return; + return true; } + final requestGeneration = ++_searchGeneration; + setState(() { _isLoading = true; _isSearchMode = true; @@ -418,7 +479,6 @@ class _CustomerComboboxWidgetState extends State { ); try { - print('[CustomerCombobox] Calling _customerService.searchCustomers...'); final result = await _customerService.searchCustomers( businessId: widget.businessId, searchQuery: query, @@ -427,7 +487,7 @@ class _CustomerComboboxWidgetState extends State { ); final customers = result['customers'] as List; - print('[CustomerCombobox] Search completed - received ${customers.length} customers'); + if (!mounted || requestGeneration != _searchGeneration) return false; setState(() { _customers = customers; @@ -435,6 +495,7 @@ class _CustomerComboboxWidgetState extends State { _hasMore = (result['hasMore'] as bool?) ?? false; _currentPage = 1; _isLoadingMore = false; + _loadedQuery = query; }); // به‌روزرسانی ValueNotifier _pickerStateNotifier.value = _CustomerPickerState( @@ -444,17 +505,19 @@ class _CustomerComboboxWidgetState extends State { hasSearched: _hasSearched, hasMore: _hasMore, ); - _highlightedIndex = _customers.isEmpty ? -1 : 0; + _highlightedIndex = -1; + _navigatedByKeyboard = false; _desktopOverlayEntry?.markNeedsBuild(); - print('[CustomerCombobox] ValueNotifier updated - customers count: ${_pickerStateNotifier.value.customers.length}'); + return true; } catch (e) { - print('[CustomerCombobox] ERROR in _searchCustomers: $e'); + if (!mounted || requestGeneration != _searchGeneration) return false; setState(() { _customers.clear(); _isLoading = false; _isLoadingMore = false; _hasMore = false; _currentPage = 1; + _loadedQuery = ''; }); // به‌روزرسانی ValueNotifier _pickerStateNotifier.value = _CustomerPickerState( @@ -465,7 +528,9 @@ class _CustomerComboboxWidgetState extends State { hasMore: _hasMore, ); _highlightedIndex = -1; + _navigatedByKeyboard = false; _desktopOverlayEntry?.markNeedsBuild(); + return false; } } @@ -478,6 +543,8 @@ class _CustomerComboboxWidgetState extends State { if (_currentQuery.trim().isEmpty) return; final nextPage = _currentPage + 1; + final requestGeneration = _searchGeneration; + final requestQuery = _currentQuery; setState(() { _isLoadingMore = true; }); @@ -488,14 +555,22 @@ class _CustomerComboboxWidgetState extends State { try { final result = await _customerService.searchCustomers( businessId: widget.businessId, - searchQuery: _currentQuery, + searchQuery: requestQuery, page: nextPage, limit: 20, ); + if (!mounted || + requestGeneration != _searchGeneration || + requestQuery != _currentQuery) { + return; + } + final newCustomers = result['customers'] as List; final existingIds = _customers.map((c) => c.id).toSet(); - final uniqueNewCustomers = newCustomers.where((c) => !existingIds.contains(c.id)).toList(); + final uniqueNewCustomers = newCustomers + .where((c) => !existingIds.contains(c.id)) + .toList(); setState(() { _customers = [..._customers, ...uniqueNewCustomers]; @@ -513,6 +588,11 @@ class _CustomerComboboxWidgetState extends State { ); _desktopOverlayEntry?.markNeedsBuild(); } catch (e) { + if (!mounted || + requestGeneration != _searchGeneration || + requestQuery != _currentQuery) { + return; + } setState(() { _isLoadingMore = false; }); @@ -522,7 +602,136 @@ class _CustomerComboboxWidgetState extends State { } } + Future _submitField() async { + if (_isQuickResolving) return; + _debounceTimer?.cancel(); + final query = _searchController.text.trim(); + var action = resolveCustomerSearchSubmitAction( + input: query, + loadedQuery: _loadedQuery, + suggestionCount: _customers.length, + hasMoreSuggestions: _hasMore, + navigatedByKeyboard: _navigatedByKeyboard, + isLoading: _isLoading, + quickCreateEnabled: widget.enableQuickCreateOnSubmit, + ); + if (action == CustomerSearchSubmitAction.search) { + final searched = await _searchCustomers(query); + if (!searched || !mounted || _searchController.text.trim() != query) { + if (mounted && !searched) { + SnackBarHelper.showError( + context, + message: 'جست‌وجوی مشتری انجام نشد؛ دوباره تلاش کنید.', + ); + } + return; + } + action = resolveCustomerSearchSubmitAction( + input: query, + loadedQuery: _loadedQuery, + suggestionCount: _customers.length, + hasMoreSuggestions: _hasMore, + navigatedByKeyboard: _navigatedByKeyboard, + isLoading: _isLoading, + quickCreateEnabled: widget.enableQuickCreateOnSubmit, + ); + } + + switch (action) { + case CustomerSearchSubmitAction.selectSuggestion: + _selectHighlighted(); + break; + case CustomerSearchSubmitAction.quickCreate: + await _quickResolveCustomer(query); + break; + case CustomerSearchSubmitAction.search: + case CustomerSearchSubmitAction.waitForSelection: + break; + } + } + + Future _quickResolveCustomer(String query) async { + final entry = parseCustomerQuickEntry(query); + if (entry == null) { + SnackBarHelper.showError( + context, + message: 'ورودی مشتری مبهم است؛ یک نام یا یک شماره موبایل وارد کنید.', + ); + return; + } + + setState(() => _isQuickResolving = true); + _desktopOverlayEntry?.markNeedsBuild(); + try { + final result = await _customerService.quickResolveCustomer( + businessId: widget.businessId, + aliasName: entry.aliasName, + mobile: entry.mobile, + ); + if (!mounted || _searchController.text.trim() != query) return; + + final customers = result['customers'] as List; + final created = result['created'] == true; + if (customers.length == 1) { + final customer = customers.single; + _selectCustomerFromOverlay(customer); + if (created) { + SnackBarHelper.show( + context, + message: '${customer.name} ثبت و انتخاب شد', + ); + } + return; + } + + if (customers.isNotEmpty) { + setState(() { + _customers = customers; + _isLoading = false; + _isSearchMode = true; + _hasSearched = true; + _isLoadingMore = false; + _hasMore = false; + _loadedQuery = query; + _currentQuery = query; + _highlightedIndex = -1; + _navigatedByKeyboard = false; + }); + _pickerStateNotifier.value = _CustomerPickerState( + customers: customers, + isLoading: false, + isLoadingMore: false, + hasSearched: true, + hasMore: false, + ); + _showDesktopOverlay(); + _desktopOverlayEntry?.markNeedsBuild(); + SnackBarHelper.show( + context, + message: 'چند مشتری مشابه پیدا شد؛ مشتری موردنظر را انتخاب کنید.', + ); + return; + } + + SnackBarHelper.showError( + context, + message: 'مشتری ثبت نشد؛ دوباره تلاش کنید.', + ); + } catch (e) { + if (mounted) { + SnackBarHelper.showError( + context, + message: ErrorExtractor.forContext(e, context), + ); + } + } finally { + if (mounted) { + setState(() => _isQuickResolving = false); + _desktopOverlayEntry?.markNeedsBuild(); + } + } + } Future _applyNewCustomerDialogResult(Person? result) async { if (result == null || !mounted) return; @@ -599,7 +808,6 @@ class _CustomerComboboxWidgetState extends State { } void _showCustomerPicker() { - print('[CustomerCombobox] _showCustomerPicker called - _customers count: ${_customers.length}'); // مقداردهی اولیه ValueNotifier _pickerStateNotifier.value = _CustomerPickerState( customers: _customers, @@ -608,29 +816,32 @@ class _CustomerComboboxWidgetState extends State { hasSearched: _hasSearched, hasMore: _hasMore, ); + _mobilePickerOpen = true; showModalBottomSheet( context: context, isScrollControlled: true, builder: (bottomSheetContext) { - print('[CustomerCombobox] BottomSheet builder called - _customers count: ${_customers.length}, _isLoading: $_isLoading'); return _CustomerPickerBottomSheet( pickerStateNotifier: _pickerStateNotifier, selectedCustomer: widget.selectedCustomer, onCustomerSelected: (customer) { + _isEditingQuery = false; + _navigatedByKeyboard = false; + _searchGeneration++; _setFieldQuiet(customer.name); widget.onCustomerChanged(customer); Navigator.pop(bottomSheetContext); }, searchController: _searchController, onSearchChanged: (query) { - print('[CustomerCombobox] onSearchChanged callback called with: "$query"'); _onSearchChanged(query); }, + onSubmitted: () => unawaited(_submitField()), onLoadMore: _loadMoreCustomers, onAddNew: () => _addNewPerson(bottomSheetContext), ); }, - ); + ).whenComplete(() => _mobilePickerOpen = false); } @override @@ -661,7 +872,9 @@ class _CustomerComboboxWidgetState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline.withValues(alpha: 0.5)), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.5), + ), borderRadius: BorderRadius.circular(8), color: colorScheme.surface, ), @@ -673,31 +886,39 @@ class _CustomerComboboxWidgetState extends State { Expanded( child: widget.selectedCustomer != null ? (inlineBalance - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.selectedCustomer!.name, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - maxLines: 1, + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.selectedCustomer!.name, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: PersonFinancialBalanceBanner( + selectedPerson: balancePerson, + ), + ), + ], + ) + : Text( + widget.selectedCustomer!.name, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: PersonFinancialBalanceBanner(selectedPerson: balancePerson), - ), - ], - ) - : Text( - widget.selectedCustomer!.name, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - maxLines: 1, - )) + overflow: TextOverflow.ellipsis, + maxLines: 1, + )) : Text( widget.hintText!, - style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface.withValues(alpha: 0.6)), + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), overflow: TextOverflow.ellipsis, maxLines: 1, ), @@ -709,10 +930,16 @@ class _CustomerComboboxWidgetState extends State { icon: Icon(Icons.add, color: colorScheme.primary, size: 22), onPressed: _addNewCustomerFromField, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + constraints: const BoxConstraints( + minWidth: 36, + minHeight: 36, + ), ), ), - Icon(Icons.arrow_drop_down, color: colorScheme.onSurface.withValues(alpha: 0.6)), + Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), ], ), ), @@ -755,21 +982,42 @@ class _CustomerComboboxWidgetState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (_isQuickResolving) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), IconButton( tooltip: 'افزودن طرف حساب جدید', - icon: Icon(Icons.add, color: colorScheme.primary, size: 20), + icon: Icon( + Icons.add, + color: colorScheme.primary, + size: 20, + ), onPressed: _addNewCustomerFromField, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - constraints: InvoiceFormFieldMetrics.compactSuffixIconConstraints, + constraints: InvoiceFormFieldMetrics + .compactSuffixIconConstraints, ), IconButton( tooltip: 'انتخاب پیشرفته', - icon: Icon(Icons.manage_search_rounded, color: colorScheme.primary, size: 20), + icon: Icon( + Icons.manage_search_rounded, + color: colorScheme.primary, + size: 20, + ), onPressed: _showCustomerPicker, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - constraints: InvoiceFormFieldMetrics.compactSuffixIconConstraints, + constraints: InvoiceFormFieldMetrics + .compactSuffixIconConstraints, ), ], ), @@ -792,21 +1040,41 @@ class _CustomerComboboxWidgetState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (_isQuickResolving) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), IconButton( tooltip: 'افزودن طرف حساب جدید', icon: Icon(Icons.add, color: colorScheme.primary), onPressed: _addNewCustomerFromField, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), ), IconButton( tooltip: 'انتخاب پیشرفته', - icon: Icon(Icons.manage_search_rounded, color: colorScheme.primary), + icon: Icon( + Icons.manage_search_rounded, + color: colorScheme.primary, + ), onPressed: _showCustomerPicker, visualDensity: VisualDensity.compact, padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), ), ], ), @@ -830,14 +1098,13 @@ class _CustomerComboboxWidgetState extends State { _onSearchChanged(query); _showDesktopOverlay(); }, - onSubmitted: (_) => _selectHighlighted(), + onSubmitted: (_) => unawaited(_submitField()), ), ); }, ), ); } - } class _CustomerPickerBottomSheet extends StatefulWidget { @@ -846,6 +1113,7 @@ class _CustomerPickerBottomSheet extends StatefulWidget { final Function(Customer) onCustomerSelected; final TextEditingController searchController; final Function(String) onSearchChanged; + final VoidCallback onSubmitted; final VoidCallback onLoadMore; final VoidCallback? onAddNew; @@ -855,15 +1123,18 @@ class _CustomerPickerBottomSheet extends StatefulWidget { required this.onCustomerSelected, required this.searchController, required this.onSearchChanged, + required this.onSubmitted, required this.onLoadMore, this.onAddNew, }); @override - State<_CustomerPickerBottomSheet> createState() => _CustomerPickerBottomSheetState(); + State<_CustomerPickerBottomSheet> createState() => + _CustomerPickerBottomSheetState(); } -class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> { +class _CustomerPickerBottomSheetState + extends State<_CustomerPickerBottomSheet> { late final ScrollController _scrollController; final FocusNode _searchFocus = FocusNode(); @@ -892,7 +1163,6 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> @override Widget build(BuildContext context) { - print('[CustomerPickerBottomSheet] build called'); final theme = Theme.of(context); return Container( @@ -938,6 +1208,7 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> ), ), onChanged: widget.onSearchChanged, + onSubmitted: (_) => widget.onSubmitted(), ), ), SizedBox( @@ -967,9 +1238,6 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> child: ValueListenableBuilder<_CustomerPickerState>( valueListenable: widget.pickerStateNotifier, builder: (context, pickerState, _) { - print( - '[CustomerPickerBottomSheet] list rebuild count=${pickerState.customers.length} loading=${pickerState.isLoading}', - ); return _buildCustomersList(context, pickerState); }, ), @@ -979,19 +1247,19 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> ); } - Widget _buildCustomersList(BuildContext context, _CustomerPickerState pickerState) { - print('[CustomerPickerBottomSheet] _buildCustomersList called - customers count: ${pickerState.customers.length}, isLoading: ${pickerState.isLoading}'); + Widget _buildCustomersList( + BuildContext context, + _CustomerPickerState pickerState, + ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; // لودینگ اولیه (وقتی هنوز دیتایی نداریم) if (pickerState.isLoading && pickerState.customers.isEmpty) { - print('[CustomerPickerBottomSheet] Showing loading indicator'); return const Center(child: CircularProgressIndicator()); } if (pickerState.customers.isEmpty) { - print('[CustomerPickerBottomSheet] Showing empty state'); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -1003,7 +1271,9 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> ), const SizedBox(height: 16), Text( - pickerState.hasSearched ? 'طرف حسابی یافت نشد' : 'هیچ طرف حسابی ثبت نشده است', + pickerState.hasSearched + ? 'طرف حسابی یافت نشد' + : 'هیچ طرف حسابی ثبت نشده است', style: theme.textTheme.bodyLarge?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.7), ), @@ -1013,7 +1283,6 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> ); } - print('[CustomerPickerBottomSheet] Building ListView with ${pickerState.customers.length} items'); return Column( children: [ // وقتی کاربر عبارت جست‌وجو را عوض می‌کند، یک لودینگ سبک نشان بده بدون اینکه لیست محو شود @@ -1021,11 +1290,18 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> Expanded( child: ListView.builder( controller: _scrollController, - itemCount: pickerState.customers.length + - ((pickerState.isLoadingMore || (pickerState.isLoading && pickerState.customers.isNotEmpty)) ? 1 : 0), + itemCount: + pickerState.customers.length + + ((pickerState.isLoadingMore || + (pickerState.isLoading && + pickerState.customers.isNotEmpty)) + ? 1 + : 0), itemBuilder: (context, index) { // فوتر برای بارگذاری صفحه بعد - if ((pickerState.isLoadingMore || (pickerState.isLoading && pickerState.customers.isNotEmpty)) && + if ((pickerState.isLoadingMore || + (pickerState.isLoading && + pickerState.customers.isNotEmpty)) && index == pickerState.customers.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -1059,10 +1335,7 @@ class _CustomerPickerBottomSheetState extends State<_CustomerPickerBottomSheet> ], ), trailing: isSelected - ? Icon( - Icons.check_circle, - color: colorScheme.primary, - ) + ? Icon(Icons.check_circle, color: colorScheme.primary) : null, onTap: () => widget.onCustomerSelected(customer), ); diff --git a/hesabixUI/hesabix_ui/test/app_config_test.dart b/hesabixUI/hesabix_ui/test/app_config_test.dart new file mode 100644 index 00000000..dfa05eb6 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/app_config_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/config/app_config.dart'; + +void main() { + group('AppConfig.resolveApiBaseUrl', () { + test('uses the explicitly configured API URL', () { + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: ' https://api.example.com ', + isWebBuild: true, + currentUri: Uri.parse('http://localhost:8080/sales'), + ), + 'https://api.example.com', + ); + }); + + test('routes a local web preview to the API development port', () { + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: '', + isWebBuild: true, + currentUri: Uri.parse('http://localhost:8080/sales'), + ), + 'http://localhost:8000', + ); + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: '', + isWebBuild: true, + currentUri: Uri.parse('http://127.0.0.1:8080/sales'), + ), + 'http://127.0.0.1:8000', + ); + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: '', + isWebBuild: true, + currentUri: Uri.parse('http://192.168.50.101:8080/business/4952'), + ), + 'http://192.168.50.101:8000', + ); + }); + + test('keeps the current origin behind a production reverse proxy', () { + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: '', + isWebBuild: true, + currentUri: Uri.parse('https://arc.example.com/sales'), + ), + 'https://arc.example.com', + ); + }); + + test('uses the local API default outside web builds', () { + expect( + AppConfig.resolveApiBaseUrl( + configuredValue: '', + isWebBuild: false, + currentUri: Uri.parse('file:///app'), + ), + 'http://localhost:8000', + ); + }); + }); +} diff --git a/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart new file mode 100644 index 00000000..29191cb6 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hesabix_ui/utils/customer_quick_entry.dart'; + +void main() { + group('customer quick entry parser', () { + test('extracts a leading mobile and keeps the full alias', () { + final entry = parseCustomerQuickEntry('09121234567 سید محمد مهدی رضوی'); + expect(entry?.mobile, '09121234567'); + expect(entry?.aliasName, 'سید محمد مهدی رضوی'); + }); + + test('extracts a trailing international mobile', () { + final entry = parseCustomerQuickEntry('زهرا سادات موسوی +989121234567'); + expect(entry?.mobile, '09121234567'); + expect(entry?.aliasName, 'زهرا سادات موسوی'); + }); + + test('normalizes Persian digits for mobile-only entry', () { + final entry = parseCustomerQuickEntry('۰۹۱۲۱۲۳۴۵۶۷'); + expect(entry?.mobile, '09121234567'); + expect(entry?.aliasName, isNull); + }); + + test('accepts a name-only entry and normalizes Persian letters', () { + final entry = parseCustomerQuickEntry(' علي رضايي '); + expect(entry?.mobile, isNull); + expect(entry?.aliasName, 'علی رضایی'); + }); + + test('rejects an ambiguous entry with two mobile numbers', () { + expect(parseCustomerQuickEntry('09121234567 علی 09351234567'), isNull); + }); + }); + + group('customer Enter policy', () { + test('selects the only loaded customer', () { + expect( + resolveCustomerSearchSubmitAction( + input: '09121234567', + loadedQuery: '09121234567', + suggestionCount: 1, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: true, + ), + CustomerSearchSubmitAction.selectSuggestion, + ); + }); + + test('selects a highlighted customer among several results', () { + expect( + resolveCustomerSearchSubmitAction( + input: 'علی', + loadedQuery: 'علی', + suggestionCount: 3, + hasMoreSuggestions: false, + navigatedByKeyboard: true, + isLoading: false, + quickCreateEnabled: true, + ), + CustomerSearchSubmitAction.selectSuggestion, + ); + }); + + test('resolves several results on the server before selection', () { + expect( + resolveCustomerSearchSubmitAction( + input: 'علی', + loadedQuery: 'علی', + suggestionCount: 3, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: true, + ), + CustomerSearchSubmitAction.quickCreate, + ); + }); + + test('waits on shared fields where quick entry is disabled', () { + expect( + resolveCustomerSearchSubmitAction( + input: 'علی', + loadedQuery: 'علی', + suggestionCount: 3, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: false, + ), + CustomerSearchSubmitAction.waitForSelection, + ); + }); + + test('quick-creates only after a current empty search', () { + expect( + resolveCustomerSearchSubmitAction( + input: '09121234567 علی رضایی', + loadedQuery: '09121234567 علی رضایی', + suggestionCount: 0, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: true, + ), + CustomerSearchSubmitAction.quickCreate, + ); + }); + + test('searches before resolving stale results', () { + expect( + resolveCustomerSearchSubmitAction( + input: 'علی رضایی', + loadedQuery: 'علی', + suggestionCount: 0, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: true, + ), + CustomerSearchSubmitAction.search, + ); + }); + }); +}