Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc

codex/quick-sales-customer-quick-entry #1

Merged
Seyyed merged 6 commits from codex/quick-sales-customer-quick-entry into master 2026-09-22 19:54:56 +03:30
12 changed files with 1160 additions and 131 deletions

View file

@ -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`

View file

@ -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(

View file

@ -846,7 +846,7 @@ async def export_kardex_pdf_endpoint(
<h3>{'گزارش کاردکس' if is_fa else 'Kardex Report'}</h3>
{(
"<div style='margin:6px 0 10px 0; font-size:11px; color:#555'>" +
"".join([f"<span style=\"display:inline-block;border:1px solid #e5e7eb;border-radius:999px;padding:3px 8px;margin:2px 4px;background:#fff\"><b>{escape(str(f.get('label')))}:</b> {escape(str(f.get('value')))}</span>" for f in (filters_summary or [])]) +
"".join([f'''<span style="display:inline-block;border:1px solid #e5e7eb;border-radius:999px;padding:3px 8px;margin:2px 4px;background:#fff"><b>{escape(str(f.get('label')))}:</b> {escape(str(f.get('value')))}</span>''' for f in (filters_summary or [])]) +
"</div>"
) if filters_summary else ""}
<table>

View file

@ -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)

View file

@ -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]

View file

@ -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)}';
}
}

View file

@ -3588,6 +3588,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
enableQuickCreateOnSubmit: true,
label: 'مشتری',
hintText: 'مشتری ناشناس',
),
@ -4471,6 +4472,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
enableQuickCreateOnSubmit: true,
label: 'مشتری',
hintText: 'مشتری ناشناس',
),
@ -4874,6 +4876,7 @@ class _QuickSalesPageState extends State<QuickSalesPage>
businessId: widget.businessId,
authStore: widget.authStore,
isRequired: false,
enableQuickCreateOnSubmit: true,
dense: true,
label: 'مشتری',
hintText: 'مشتری ناشناس',

View file

@ -32,7 +32,7 @@ class CustomerService {
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
// تبدیل لیست مشتری‌ها
final customersJson = data['customers'] as List<dynamic>;
final customers = customersJson
@ -54,6 +54,30 @@ class CustomerService {
}
}
/// مشتری موجود را برمی‌گرداند یا پس از بررسی تکراری، مشتری جدید می‌سازد.
Future<Map<String, dynamic>> 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<String, dynamic>.from(response.data as Map);
final customers = (data['customers'] as List<dynamic>? ?? const [])
.map(
(json) => Customer.fromJson(Map<String, dynamic>.from(json as Map)),
)
.toList();
return {'customers': customers, 'created': data['created'] == true};
}
/// دریافت اطلاعات یک مشتری خاص
Future<Customer?> 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;

View file

@ -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;
}

View file

@ -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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
_desktopOverlayEntry?.remove();
_desktopOverlayEntry = null;
_highlightedIndex = -1;
_navigatedByKeyboard = false;
}
void _onDesktopOverlayScroll() {
@ -236,13 +258,22 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
);
}
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
}
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
_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<CustomerComboboxWidget> {
Future<void> _loadRecentCustomers() async {
if (_hasLoadedRecent && !_isSearchMode) return;
final requestGeneration = ++_searchGeneration;
setState(() {
_isLoading = true;
});
@ -350,6 +402,8 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
limit: 5,
);
if (!mounted || requestGeneration != _searchGeneration) return;
setState(() {
_customers = result['customers'] as List<Customer>;
_isLoading = false;
@ -360,6 +414,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
_hasMore = false; // در حالت "اخیرها" صفحه‌بندی نداریم
_currentPage = 1;
_currentQuery = '';
_loadedQuery = '';
});
// به‌روزرسانی ValueNotifier
_pickerStateNotifier.value = _CustomerPickerState(
@ -369,9 +424,11 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
}
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<void> _searchCustomers(String query) async {
print('[CustomerCombobox] _searchCustomers called with query: "$query"');
Future<bool> _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<CustomerComboboxWidget> {
);
try {
print('[CustomerCombobox] Calling _customerService.searchCustomers...');
final result = await _customerService.searchCustomers(
businessId: widget.businessId,
searchQuery: query,
@ -427,7 +487,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
);
final customers = result['customers'] as List<Customer>;
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<CustomerComboboxWidget> {
_hasMore = (result['hasMore'] as bool?) ?? false;
_currentPage = 1;
_isLoadingMore = false;
_loadedQuery = query;
});
// به‌روزرسانی ValueNotifier
_pickerStateNotifier.value = _CustomerPickerState(
@ -444,17 +505,19 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
hasMore: _hasMore,
);
_highlightedIndex = -1;
_navigatedByKeyboard = false;
_desktopOverlayEntry?.markNeedsBuild();
return false;
}
}
@ -478,6 +543,8 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<Customer>;
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<CustomerComboboxWidget> {
);
_desktopOverlayEntry?.markNeedsBuild();
} catch (e) {
if (!mounted ||
requestGeneration != _searchGeneration ||
requestQuery != _currentQuery) {
return;
}
setState(() {
_isLoadingMore = false;
});
@ -522,7 +602,136 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
}
}
Future<void> _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<void> _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<Customer>;
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<void> _applyNewCustomerDialogResult(Person? result) async {
if (result == null || !mounted) return;
@ -599,7 +808,6 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
}
void _showCustomerPicker() {
print('[CustomerCombobox] _showCustomerPicker called - _customers count: ${_customers.length}');
// مقداردهی اولیه ValueNotifier
_pickerStateNotifier.value = _CustomerPickerState(
customers: _customers,
@ -608,29 +816,32 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
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<CustomerComboboxWidget> {
_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),
);

View file

@ -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',
);
});
});
}

View file

@ -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,
);
});
});
}