codex/quick-sales-customer-quick-entry #5
12 changed files with 1160 additions and 131 deletions
55
docs/quick-sales-customer-field-behavior.md
Normal file
55
docs/quick-sales-customer-field-behavior.md
Normal 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`
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
206
hesabixAPI/app/services/customer_quick_entry_service.py
Normal file
206
hesabixAPI/app/services/customer_quick_entry_service.py
Normal 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)
|
||||
116
hesabixAPI/tests/test_customer_quick_entry_service.py
Normal file
116
hesabixAPI/tests/test_customer_quick_entry_service.py
Normal 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]
|
||||
|
|
@ -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)}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 'مشتری ناشناس',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
104
hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart
Normal file
104
hesabixUI/hesabix_ui/lib/utils/customer_quick_entry.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
66
hesabixUI/hesabix_ui/test/app_config_test.dart
Normal file
66
hesabixUI/hesabix_ui/test/app_config_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
126
hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart
Normal file
126
hesabixUI/hesabix_ui/test/customer_quick_entry_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue