From 799af82c25803a28e568407b09e25de45ed3696b Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 22 Sep 2026 03:26:19 +0330 Subject: [PATCH 1/5] feat: add quick customer entry to quick sales --- docs/quick-sales-customer-field-behavior.md | 52 ++ hesabixAPI/adapters/api/v1/customers.py | 84 +++- .../services/customer_quick_entry_service.py | 177 +++++++ .../test_customer_quick_entry_service.py | 76 +++ .../lib/pages/business/quick_sales_page.dart | 3 + .../lib/services/customer_service.dart | 30 +- .../lib/utils/customer_quick_entry.dart | 100 ++++ .../invoice/customer_combobox_widget.dart | 463 ++++++++++++++---- .../test/customer_quick_entry_test.dart | 111 +++++ 9 files changed, 973 insertions(+), 123 deletions(-) create mode 100644 docs/quick-sales-customer-field-behavior.md create mode 100644 hesabixAPI/app/services/customer_quick_entry_service.py create mode 100644 hesabixAPI/tests/test_customer_quick_entry_service.py create mode 100644 hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart create mode 100644 hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart diff --git a/docs/quick-sales-customer-field-behavior.md b/docs/quick-sales-customer-field-behavior.md new file mode 100644 index 00000000..b4e6b645 --- /dev/null +++ b/docs/quick-sales-customer-field-behavior.md @@ -0,0 +1,52 @@ +# رفتار فیلد مشتری در فروش سریع + +این سند رفتار جست‌وجو، انتخاب و ثبت سریع مشتری در صفحهٔ فروش سریع را تعریف می‌کند. ثبت سریع با `enableQuickCreateOnSubmit` فقط برای نمونه‌های این فیلد در فروش سریع فعال است؛ سایر مصرف‌کنندگان `CustomerComboboxWidget` بدون درخواست صریح مشتری جدید ایجاد نمی‌کنند. + +## رفتار Enter + +ترتیب تصمیم‌گیری پس از زدن Enter به شکل زیر است: + +1. اگر نتایج متن فعلی هنوز بارگذاری نشده‌اند، ابتدا جست‌وجو کامل می‌شود. +2. اگر دقیقاً یک نتیجه وجود داشته باشد، همان مشتری انتخاب می‌شود. +3. اگر چند نتیجه وجود داشته باشد و کاربر با جهت‌نما ردیفی را مشخص کرده باشد، همان ردیف انتخاب می‌شود. +4. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، عملی انجام نمی‌شود و کاربر باید با جهت‌نما یا موس انتخاب کند. +5. اگر نتیجه‌ای وجود نداشته باشد، ورودی برای یافتن یا ساختن سریع مشتری به API ارسال می‌شود. + +پاسخ جست‌وجوی قدیمی اجازه ندارد نتایج متن جدید را جایگزین کند. اولین ردیف نیز صرفاً به‌دلیل قرارگرفتن در ابتدای فهرست، انتخاب‌شده محسوب نمی‌شود. + +## قالب‌های ورودی ثبت سریع + +ورودی می‌تواند شامل نام، موبایل یا هر دو باشد. ترتیب نام و موبایل مهم نیست و تمام بخش نام به‌صورت یکپارچه در `alias_name` قرار می‌گیرد. + +| ورودی | نام مستعار | موبایل | +|---|---|---| +| `علی رضایی` | `علی رضایی` | خالی | +| `09121234567` | `09121234567` | `09121234567` | +| `09121234567 سید محمد مهدی رضوی` | `سید محمد مهدی رضوی` | `09121234567` | +| `زهرا سادات موسوی +989121234567` | `زهرا سادات موسوی` | `09121234567` | +| `۰۹۱۲۱۲۳۴۵۶۷ کاظمی` | `کاظمی` | `09121234567` | + +اعداد فارسی و عربی به انگلیسی تبدیل می‌شوند. قالب‌های `09xxxxxxxxx`، `9xxxxxxxxx`، `+989xxxxxxxxx`، `989xxxxxxxxx` و `00989xxxxxxxxx` پشتیبانی می‌شوند. فاصله و خط تیره داخل شماره نیز پذیرفته می‌شود. وجود بیش از یک موبایل معتبر، ورودی را مبهم می‌کند و ثبت خودکار انجام نمی‌شود. + +در ورودی فقط موبایل، به‌دلیل الزامی‌بودن نام مستعار شخص، موبایل استانداردشده به‌عنوان نام مستعار نیز ذخیره می‌شود. + +## جلوگیری از ثبت تکراری + +API پیش از ایجاد شخص، تمام اشخاص همان کسب‌وکار را دوباره بررسی می‌کند: + +- موبایل پس از استانداردسازی با فیلدهای موبایل، موبایل دوم، موبایل سوم و تلفن مقایسه می‌شود. +- نام با یکسان‌سازی فاصله‌ها، نیم‌فاصله و حروف عربی/فارسی مقایسه می‌شود. +- نام مستعار، نام، نام خانوادگی، نام شرکت و ترکیب نام و نام خانوادگی در بررسی تشابه شرکت دارند. +- اگر یک شخص مشابه پیدا شود، همان شخص انتخاب می‌شود و رکورد جدید ساخته نمی‌شود. +- اگر چند شخص مشابه پیدا شوند، فهرست آن‌ها نمایش داده می‌شود تا کاربر انتخاب کند. +- در PostgreSQL، بررسی و ایجاد سریع برای هر کسب‌وکار با قفل تراکنشی سریالی می‌شود تا دو درخواست هم‌زمان رکورد تکراری نسازند. + +شخصی که واقعاً جدید باشد با نوع «مشتری» ساخته می‌شود و بدون بازشدن فرم شخص، برای فاکتور جاری انتخاب می‌شود. + +## مسیرهای پیاده‌سازی + +- رابط و رفتار صفحه‌کلید: `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/app/services/customer_quick_entry_service.py b/hesabixAPI/app/services/customer_quick_entry_service.py new file mode 100644 index 00000000..4ded5ad0 --- /dev/null +++ b/hesabixAPI/app/services/customer_quick_entry_service.py @@ -0,0 +1,177 @@ +"""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 find_quick_customer_matches( + persons: Iterable[Person], + *, + alias_name: str | None, + mobile: str | None, + limit: int = 20, +) -> list[Person]: + matches: list[Person] = [] + for person in persons: + if person_matches_quick_customer_entry( + person, + alias_name=alias_name, + mobile=mobile, + ): + matches.append(person) + if len(matches) >= limit: + break + return matches + + +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..8e77b9f8 --- /dev/null +++ b/hesabixAPI/tests/test_customer_quick_entry_service.py @@ -0,0 +1,76 @@ +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] 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..fda932c8 --- /dev/null +++ b/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart @@ -0,0 +1,100 @@ +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) { + return 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/customer_quick_entry_test.dart b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart new file mode 100644 index 00000000..4d8480c5 --- /dev/null +++ b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart @@ -0,0 +1,111 @@ +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('waits when several results exist without navigation', () { + expect( + resolveCustomerSearchSubmitAction( + input: 'علی', + loadedQuery: 'علی', + suggestionCount: 3, + hasMoreSuggestions: false, + navigatedByKeyboard: false, + isLoading: false, + quickCreateEnabled: true, + ), + 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, + ); + }); + }); +} -- 2.43.0 From 2d8a2f2bd071729aa2e108db53c637c29d98e6d2 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 22 Sep 2026 04:01:32 +0330 Subject: [PATCH 2/5] fix: resolve quick customer matches before selection --- docs/quick-sales-customer-field-behavior.md | 3 +- .../services/customer_quick_entry_service.py | 49 +++++++++++++++---- .../test_customer_quick_entry_service.py | 40 +++++++++++++++ .../lib/utils/customer_quick_entry.dart | 6 ++- .../test/customer_quick_entry_test.dart | 17 ++++++- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/docs/quick-sales-customer-field-behavior.md b/docs/quick-sales-customer-field-behavior.md index b4e6b645..31d85c0b 100644 --- a/docs/quick-sales-customer-field-behavior.md +++ b/docs/quick-sales-customer-field-behavior.md @@ -9,7 +9,7 @@ 1. اگر نتایج متن فعلی هنوز بارگذاری نشده‌اند، ابتدا جست‌وجو کامل می‌شود. 2. اگر دقیقاً یک نتیجه وجود داشته باشد، همان مشتری انتخاب می‌شود. 3. اگر چند نتیجه وجود داشته باشد و کاربر با جهت‌نما ردیفی را مشخص کرده باشد، همان ردیف انتخاب می‌شود. -4. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، عملی انجام نمی‌شود و کاربر باید با جهت‌نما یا موس انتخاب کند. +4. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، API تطبیق دقیق و نتایج مشابه را بررسی می‌کند؛ نتیجه‌های مشابه در همان منوی شناور می‌مانند تا کاربر با جهت‌نما و Enter یا موس انتخاب کند. 5. اگر نتیجه‌ای وجود نداشته باشد، ورودی برای یافتن یا ساختن سریع مشتری به API ارسال می‌شود. پاسخ جست‌وجوی قدیمی اجازه ندارد نتایج متن جدید را جایگزین کند. اولین ردیف نیز صرفاً به‌دلیل قرارگرفتن در ابتدای فهرست، انتخاب‌شده محسوب نمی‌شود. @@ -39,6 +39,7 @@ API پیش از ایجاد شخص، تمام اشخاص همان کسب‌وکا - نام مستعار، نام، نام خانوادگی، نام شرکت و ترکیب نام و نام خانوادگی در بررسی تشابه شرکت دارند. - اگر یک شخص مشابه پیدا شود، همان شخص انتخاب می‌شود و رکورد جدید ساخته نمی‌شود. - اگر چند شخص مشابه پیدا شوند، فهرست آن‌ها نمایش داده می‌شود تا کاربر انتخاب کند. +- تطبیق موبایل دقیق بر تطبیق نام و تطبیق نام دقیق بر نام‌های جزئی اولویت دارد. - در PostgreSQL، بررسی و ایجاد سریع برای هر کسب‌وکار با قفل تراکنشی سریالی می‌شود تا دو درخواست هم‌زمان رکورد تکراری نسازند. شخصی که واقعاً جدید باشد با نوع «مشتری» ساخته می‌شود و بدون بازشدن فرم شخص، برای فاکتور جاری انتخاب می‌شود. diff --git a/hesabixAPI/app/services/customer_quick_entry_service.py b/hesabixAPI/app/services/customer_quick_entry_service.py index 4ded5ad0..8a65426d 100644 --- a/hesabixAPI/app/services/customer_quick_entry_service.py +++ b/hesabixAPI/app/services/customer_quick_entry_service.py @@ -95,6 +95,16 @@ def person_matches_quick_customer_entry( 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], *, @@ -102,17 +112,36 @@ def find_quick_customer_matches( mobile: str | None, limit: int = 20, ) -> list[Person]: - matches: list[Person] = [] - for person in persons: - if person_matches_quick_customer_entry( - person, - alias_name=alias_name, - mobile=mobile, + 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 ): - matches.append(person) - if len(matches) >= limit: - break - return matches + similar_matches.append(person) + + # نام دقیق نیز قبل از نتایج جزئی انتخاب می‌شود. چند رکورد دقیق همچنان برای + # انتخاب صریح کاربر برگردانده می‌شوند و رکورد تازه ساخته نمی‌شود. + return (exact_matches or similar_matches)[:limit] def resolve_or_create_quick_customer( diff --git a/hesabixAPI/tests/test_customer_quick_entry_service.py b/hesabixAPI/tests/test_customer_quick_entry_service.py index 8e77b9f8..a3f4929d 100644 --- a/hesabixAPI/tests/test_customer_quick_entry_service.py +++ b/hesabixAPI/tests/test_customer_quick_entry_service.py @@ -74,3 +74,43 @@ def test_finds_name_split_between_person_fields(): 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/utils/customer_quick_entry.dart b/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart index fda932c8..79b42319 100644 --- a/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart +++ b/hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart @@ -92,7 +92,11 @@ CustomerSearchSubmitAction resolveCustomerSearchSubmitAction({ return CustomerSearchSubmitAction.selectSuggestion; } if (suggestionCount > 0) { - return CustomerSearchSubmitAction.waitForSelection; + // در فروش سریع، API باید تشخیص دهد نتیجه دقیق است یا چند مشتری مشابه + // باید برای انتخاب صریح کاربر نمایش داده شوند. + return quickCreateEnabled + ? CustomerSearchSubmitAction.quickCreate + : CustomerSearchSubmitAction.waitForSelection; } return quickCreateEnabled ? CustomerSearchSubmitAction.quickCreate diff --git a/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart index 4d8480c5..29191cb6 100644 --- a/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart +++ b/hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart @@ -63,7 +63,7 @@ void main() { ); }); - test('waits when several results exist without navigation', () { + test('resolves several results on the server before selection', () { expect( resolveCustomerSearchSubmitAction( input: 'علی', @@ -74,6 +74,21 @@ void main() { 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, ); }); -- 2.43.0 From ea5383272c16ce8bb693cbd4b60ff41fd887135e Mon Sep 17 00:00:00 2001 From: Mohammad Date: Mon, 21 Sep 2026 14:54:03 +0330 Subject: [PATCH 3/5] fix(kardex): remove invalid backslashes from f-string --- hesabixAPI/adapters/api/v1/kardex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ""} -- 2.43.0 From 724212f222cac2e65a3cebe61fbaf2637637edd3 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 22 Sep 2026 04:43:00 +0330 Subject: [PATCH 4/5] fix: route local web requests to the API port --- docs/quick-sales-customer-field-behavior.md | 2 + .../hesabix_ui/lib/config/app_config.dart | 34 ++++++++--- .../hesabix_ui/test/app_config_test.dart | 58 +++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 hesabixUI/hesabix_ui/test/app_config_test.dart diff --git a/docs/quick-sales-customer-field-behavior.md b/docs/quick-sales-customer-field-behavior.md index 31d85c0b..c79fd4b7 100644 --- a/docs/quick-sales-customer-field-behavior.md +++ b/docs/quick-sales-customer-field-behavior.md @@ -44,6 +44,8 @@ API پیش از ایجاد شخص، تمام اشخاص همان کسب‌وکا شخصی که واقعاً جدید باشد با نوع «مشتری» ساخته می‌شود و بدون بازشدن فرم شخص، برای فاکتور جاری انتخاب می‌شود. +در پیش‌نمایش وب محلی، اگر `API_BASE_URL` صریحاً تنظیم نشده باشد، درخواست‌ها به `localhost:8000` ارسال می‌شوند. این fallback مانع ارسال اشتباه `POST` به وب‌سرور Flutter روی پورت ۸۰۸۰ و دریافت خطای `405 Method Not Allowed` می‌شود. در محیط‌های غیرمحلی، همان origin و reverse proxy برنامه استفاده می‌شود. + ## مسیرهای پیاده‌سازی - رابط و رفتار صفحه‌کلید: `hesabixUI/hesabix_ui/lib/widgets/invoice/customer_combobox_widget.dart` diff --git a/hesabixUI/hesabix_ui/lib/config/app_config.dart b/hesabixUI/hesabix_ui/lib/config/app_config.dart index 728a4597..fef11911 100755 --- a/hesabixUI/hesabix_ui/lib/config/app_config.dart +++ b/hesabixUI/hesabix_ui/lib/config/app_config.dart @@ -15,14 +15,36 @@ 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(); + if (host == 'localhost' || host == '127.0.0.1') { + // Flutter web-server روی 8080 فقط فایل استاتیک سرو می‌کند. فرستادن + // POSTهای API به همان origin پاسخ 405 می‌دهد، پس اجرای محلی بدون + // dart-define نیز باید مستقیماً API توسعه را روی 8000 هدف بگیرد. + 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 +75,3 @@ class AppConfig { return '$b/login?reset_token=${Uri.encodeComponent(token)}'; } } - - 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..579731be --- /dev/null +++ b/hesabixUI/hesabix_ui/test/app_config_test.dart @@ -0,0 +1,58 @@ +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', + ); + }); + + 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', + ); + }); + }); +} -- 2.43.0 From 6ca3002f16ec48ce96712a4b839b410d124d25f2 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Tue, 22 Sep 2026 05:06:46 +0330 Subject: [PATCH 5/5] fix: route LAN previews to the API port --- docs/quick-sales-customer-field-behavior.md | 2 +- hesabixUI/hesabix_ui/lib/config/app_config.dart | 8 +++++--- hesabixUI/hesabix_ui/test/app_config_test.dart | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/quick-sales-customer-field-behavior.md b/docs/quick-sales-customer-field-behavior.md index c79fd4b7..f6dbdd4e 100644 --- a/docs/quick-sales-customer-field-behavior.md +++ b/docs/quick-sales-customer-field-behavior.md @@ -44,7 +44,7 @@ API پیش از ایجاد شخص، تمام اشخاص همان کسب‌وکا شخصی که واقعاً جدید باشد با نوع «مشتری» ساخته می‌شود و بدون بازشدن فرم شخص، برای فاکتور جاری انتخاب می‌شود. -در پیش‌نمایش وب محلی، اگر `API_BASE_URL` صریحاً تنظیم نشده باشد، درخواست‌ها به `localhost:8000` ارسال می‌شوند. این fallback مانع ارسال اشتباه `POST` به وب‌سرور Flutter روی پورت ۸۰۸۰ و دریافت خطای `405 Method Not Allowed` می‌شود. در محیط‌های غیرمحلی، همان origin و reverse proxy برنامه استفاده می‌شود. +در پیش‌نمایش وب محلی، اگر `API_BASE_URL` صریحاً تنظیم نشده باشد، درخواست‌ها به پورت `8000` همان میزبان ارسال می‌شوند؛ برای نمونه، رابط `192.168.50.101:8080` از API آدرس `192.168.50.101:8000` استفاده می‌کند. این fallback مانع ارسال اشتباه درخواست‌ها به وب‌سرور Flutter روی پورت ۸۰۸۰ و دریافت خطای `405 Method Not Allowed` می‌شود. در محیط‌های غیرمحلی، همان origin و reverse proxy برنامه استفاده می‌شود. ## مسیرهای پیاده‌سازی diff --git a/hesabixUI/hesabix_ui/lib/config/app_config.dart b/hesabixUI/hesabix_ui/lib/config/app_config.dart index fef11911..1a4e3cd8 100755 --- a/hesabixUI/hesabix_ui/lib/config/app_config.dart +++ b/hesabixUI/hesabix_ui/lib/config/app_config.dart @@ -32,10 +32,12 @@ class AppConfig { if (isWebBuild) { final host = currentUri.host.toLowerCase(); - if (host == 'localhost' || host == '127.0.0.1') { + final isLocalPreview = + host == 'localhost' || host == '127.0.0.1' || currentUri.port == 8080; + if (isLocalPreview) { // Flutter web-server روی 8080 فقط فایل استاتیک سرو می‌کند. فرستادن - // POSTهای API به همان origin پاسخ 405 می‌دهد، پس اجرای محلی بدون - // dart-define نیز باید مستقیماً API توسعه را روی 8000 هدف بگیرد. + // درخواست‌های API به همان origin خطا می‌دهد. این تشخیص شامل IP شبکه + // نیز هست تا پیش‌نمایش روی دستگاه دیگری API همان میزبان را ببیند. return Uri( scheme: currentUri.scheme, host: currentUri.host, diff --git a/hesabixUI/hesabix_ui/test/app_config_test.dart b/hesabixUI/hesabix_ui/test/app_config_test.dart index 579731be..dfa05eb6 100644 --- a/hesabixUI/hesabix_ui/test/app_config_test.dart +++ b/hesabixUI/hesabix_ui/test/app_config_test.dart @@ -31,6 +31,14 @@ void main() { ), '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', () { -- 2.43.0