codex/quick-sales-customer-mobile-identity #6

Merged
morrning merged 5 commits from Seyyed/Seyyed_arc:codex/quick-sales-customer-mobile-identity into master 2026-09-24 12:21:44 +03:30
7 changed files with 137 additions and 27 deletions

View file

@ -6,14 +6,23 @@
ترتیب تصمیم‌گیری پس از زدن Enter به شکل زیر است:
1. اگر نتایج متن فعلی هنوز بارگذاری نشده‌اند، ابتدا جست‌وجو کامل می‌شود.
2. اگر دقیقاً یک نتیجه وجود داشته باشد، همان مشتری انتخاب می‌شود.
3. اگر چند نتیجه وجود داشته باشد و کاربر با جهت‌نما ردیفی را مشخص کرده باشد، همان ردیف انتخاب می‌شود.
4. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، API تطبیق دقیق و نتایج مشابه را بررسی می‌کند؛ نتیجه‌های مشابه در همان منوی شناور می‌مانند تا کاربر با جهت‌نما و Enter یا موس انتخاب کند.
5. اگر نتیجه‌ای وجود نداشته باشد، ورودی برای یافتن یا ساختن سریع مشتری به API ارسال می‌شود.
1. اگر ورودی موبایل داشته باشد و کاربر ردیفی را با جهت‌نما مشخص نکرده باشد، ورودی مستقیماً برای تطبیق یا ثبت بر اساس موبایل به API ارسال می‌شود؛ نتیجهٔ جست‌وجوی عمومی مانع این کار نیست.
2. اگر ورودی فقط نام داشته باشد و نتایج متن فعلی هنوز بارگذاری نشده‌اند، ابتدا جست‌وجو کامل می‌شود.
3. اگر دقیقاً یک نتیجه وجود داشته باشد، همان مشتری انتخاب می‌شود.
4. اگر چند نتیجه وجود داشته باشد و کاربر با جهت‌نما ردیفی را مشخص کرده باشد، همان ردیف انتخاب می‌شود.
5. اگر چند نتیجه وجود داشته باشد و ردیفی صریحاً مشخص نشده باشد، API تطبیق دقیق و نتایج مشابه را بررسی می‌کند؛ نتیجه‌های مشابه در همان منوی شناور می‌مانند تا کاربر با جهت‌نما و Enter یا موس انتخاب کند.
6. اگر نتیجه‌ای وجود نداشته باشد، ورودی برای یافتن یا ساختن سریع مشتری به API ارسال می‌شود.
پاسخ جست‌وجوی قدیمی اجازه ندارد نتایج متن جدید را جایگزین کند. اولین ردیف نیز صرفاً به‌دلیل قرارگرفتن در ابتدای فهرست، انتخاب‌شده محسوب نمی‌شود.
## فوکوس و متن موقت
با اولین فوکوس روی فیلد، کل نام مشتری فعلی انتخاب می‌شود تا اولین حرف یا رقم، متن قبلی را جایگزین کند. کلیک بعدی در همان وضعیت فوکوس همچنان اجازه می‌دهد مکان‌نما در نقطه دلخواه قرار بگیرد.
متنی که کاربر تایپ می‌کند تا زمان انتخاب یا ثبت سریع، پیش‌نویس محلی است. تایپ عادی مشتری فعلی فاکتور را به مشتری ناشناس تغییر نمی‌دهد. تنها انتخاب صریح از فهرست یا پاسخ موفق ثبت سریع، مشتری فاکتور را تغییر می‌دهد.
Enter هنگام ثبت سریع فوکوس فیلد را تا دریافت پاسخ API نگه می‌دارد. اگر کاربر در این فاصله متن را تغییر ندهد یا مشتری دیگری انتخاب نکند، پاسخ موفق حتی در صورت خروج فوکوس نیز معتبر است و مشتری ساخته‌شده را روی فاکتور قرار می‌دهد. به این ترتیب بازگردانی متن مشتری قبلی نمی‌تواند نتیجهٔ ثبت موفق را خنثی کند.
## قالب‌های ورودی ثبت سریع
ورودی می‌تواند شامل نام، موبایل یا هر دو باشد. ترتیب نام و موبایل مهم نیست و تمام بخش نام به‌صورت یکپارچه در `alias_name` قرار می‌گیرد.
@ -37,9 +46,10 @@ API پیش از ایجاد شخص، کاندیداهای همان کسب‌وک
- موبایل پس از استانداردسازی با فیلدهای موبایل، موبایل دوم، موبایل سوم و تلفن مقایسه می‌شود.
- نام با یکسان‌سازی فاصله‌ها، نیم‌فاصله و حروف عربی/فارسی مقایسه می‌شود.
- نام مستعار، نام، نام خانوادگی، نام شرکت و ترکیب نام و نام خانوادگی در بررسی تشابه شرکت دارند.
- اگر یک شخص مشابه پیدا شود، همان شخص انتخاب می‌شود و رکورد جدید ساخته نمی‌شود.
- اگر چند شخص مشابه پیدا شوند، فهرست آن‌ها نمایش داده می‌شود تا کاربر انتخاب کند.
- تطبیق موبایل دقیق بر تطبیق نام و تطبیق نام دقیق بر نام‌های جزئی اولویت دارد.
- اگر ورودی موبایل داشته باشد، فقط همان موبایل استانداردشده معیار تکراری‌بودن است؛ نام یکسان یا مشابه بررسی نمی‌شود.
- اگر همان موبایل در هیچ‌یک از فیلدهای موبایل، موبایل دوم، موبایل سوم یا تلفن پیدا نشود، مشتری جدید حتی با نام کاملاً یکسان بدون هشدار ساخته می‌شود.
- اگر یک رکورد با همان موبایل پیدا شود، همان مشتری انتخاب می‌شود. چند رکورد با موبایل یکسان در فهرست می‌مانند تا کاربر انتخاب کند.
- در ورودی فقط نام، نام یکسان یا مشابه می‌تواند شخص موجود را برگرداند یا چند گزینه برای انتخاب نمایش دهد.
- در PostgreSQL، بررسی و ایجاد سریع برای هر کسب‌وکار با قفل تراکنشی سریالی می‌شود تا دو درخواست هم‌زمان رکورد تکراری نسازند.
- endpoint فقط برای اعضای همان کسب‌وکار مجاز است (`can_access_business`).

View file

@ -82,8 +82,8 @@ def _customer_response(person: Person) -> CustomerResponse:
"/quick-resolve",
summary="یافتن یا ایجاد سریع مشتری",
description=(
"مشتری موجود را با موبایل یا نام مشابه برمی‌گرداند و تنها در صورت "
"نبود نتیجه، مشتری جدید ایجاد می‌کند"
"مشتری موجود را با موبایل یا نام برمی‌گرداند و تنها در صورت "
"نبود نتیجه، مشتری جدید ایجاد می‌کند. هنگام ارسال موبایل، فقط موبایل معیار تطبیق است"
),
response_model=CustomerQuickResolveResponse,
)

View file

@ -112,8 +112,12 @@ def person_matches_quick_customer_entry(
person.mobile_3,
person.phone,
)
if any(numbers_match(mobile, stored, min_len=10) for stored in stored_numbers):
return True
# When a mobile is supplied it is the customer identity. A matching
# name with another mobile must remain eligible for a new record.
return any(
numbers_match(mobile, stored, min_len=10)
for stored in stored_numbers
)
normalized_alias = normalize_customer_alias(alias_name)
if normalized_alias:
@ -143,14 +147,12 @@ def find_quick_customer_matches(
) -> list[Person]:
candidates = list(persons)
# موبایل شناسه قوی‌تری از نام است. اگر پیدا شود، شباهت نام نباید اشخاص
# دیگری را وارد نتیجه کند و جلوی انتخاب خودکار مشتری موجود را بگیرد.
# با وجود موبایل، نام هیچ‌وقت معیار تطبیق نیست. این کار اجازه می‌دهد
# دو مشتری هم‌نام با موبایل‌های متفاوت بدون هشدار ثبت شوند.
if mobile:
mobile_matches = [
return [
person for person in candidates if _person_matches_mobile(person, mobile)
]
if mobile_matches:
return mobile_matches[:limit]
][:limit]
normalized_alias = normalize_customer_alias(alias_name)
if not normalized_alias:
@ -206,7 +208,9 @@ def load_quick_customer_candidates(
for column in phone_columns:
filters.append(and_(column.isnot(None), column.ilike(like)))
if alias_name:
# If a mobile is present, only load phone candidates. Mixing common-name
# candidates into this bounded query could hide the actual mobile match.
if alias_name and not mobile:
variants = alias_search_variants(alias_name)
name_columns = (
Person.alias_name,

View file

@ -109,6 +109,33 @@ def test_mobile_match_takes_priority_over_similar_names():
assert [person.id for person in matches] == [1]
def test_different_mobile_does_not_match_an_identical_name():
persons = [
_person(1, "موسوی", mobile="09207201219"),
_person(2, "موسوی فروشگاه", mobile="09351234567"),
]
matches = find_quick_customer_matches(
persons,
alias_name="موسوی",
mobile="09171006219",
)
assert matches == []
def test_same_mobile_returns_all_duplicates_regardless_of_name():
persons = [
_person(1, "موسوی", mobile="09171006219"),
_person(2, "مشتری دیگر", mobile="+989171006219"),
_person(3, "موسوی", mobile="09207201219"),
]
matches = find_quick_customer_matches(
persons,
alias_name="موسوی",
mobile="09171006219",
)
assert [person.id for person in matches] == [1, 2]
def test_exact_name_takes_priority_over_partial_names():
persons = [
_person(1, "علی رضایی فروشگاه"),

View file

@ -82,9 +82,16 @@ CustomerSearchSubmitAction resolveCustomerSearchSubmitAction({
required bool navigatedByKeyboard,
required bool isLoading,
required bool quickCreateEnabled,
bool inputHasMobile = false,
}) {
final query = input.trim();
if (query.isEmpty) return CustomerSearchSubmitAction.waitForSelection;
// A mobile is the authoritative identity for quick entry. Resolve it on the
// server even if the generic text search is stale, empty, or has one result.
// Explicit keyboard navigation still wins so Enter selects that row.
if (quickCreateEnabled && inputHasMobile && !navigatedByKeyboard) {
return CustomerSearchSubmitAction.quickCreate;
}
if (isLoading || loadedQuery.trim() != query) {
return CustomerSearchSubmitAction.search;
}

View file

@ -173,6 +173,17 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
void _onDesktopFocusChanged() {
if (!mounted || _isMobile) return;
if (_fieldFocus.hasFocus) {
// TextField's web tap handling can collapse the selection after focus.
// Apply the selection at the end of the frame so the first keystroke
// reliably replaces the current customer name.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_fieldFocus.hasFocus) return;
final textLength = _searchController.text.length;
_searchController.selection = TextSelection(
baseOffset: 0,
extentOffset: textLength,
);
});
_showDesktopOverlay();
if (_searchController.text.trim().isEmpty) {
_loadRecentCustomers();
@ -181,7 +192,10 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
Future.delayed(const Duration(milliseconds: 180), () {
if (!mounted || _fieldFocus.hasFocus) return;
_removeDesktopOverlay();
if (_isEditingQuery) {
// Enter may unfocus the field while quick-resolve is still awaiting the
// API. Restoring the previous customer here would make the successful
// response look stale even though the new person was already created.
if (_isEditingQuery && !_isQuickResolving) {
_isEditingQuery = false;
_setFieldQuiet(widget.selectedCustomer?.name ?? '');
}
@ -606,6 +620,9 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
if (_isQuickResolving) return;
_debounceTimer?.cancel();
final query = _searchController.text.trim();
final quickEntry = widget.enableQuickCreateOnSubmit
? parseCustomerQuickEntry(query)
: null;
var action = resolveCustomerSearchSubmitAction(
input: query,
loadedQuery: _loadedQuery,
@ -614,6 +631,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
navigatedByKeyboard: _navigatedByKeyboard,
isLoading: _isLoading,
quickCreateEnabled: widget.enableQuickCreateOnSubmit,
inputHasMobile: quickEntry?.mobile != null,
);
if (action == CustomerSearchSubmitAction.search) {
@ -635,6 +653,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
navigatedByKeyboard: _navigatedByKeyboard,
isLoading: _isLoading,
quickCreateEnabled: widget.enableQuickCreateOnSubmit,
inputHasMobile: quickEntry?.mobile != null,
);
}
@ -661,6 +680,9 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
return;
}
// Typing or selecting another value increments this generation and safely
// invalidates the response. Focus loss and display restoration do not.
final requestGeneration = ++_searchGeneration;
setState(() => _isQuickResolving = true);
_desktopOverlayEntry?.markNeedsBuild();
try {
@ -669,7 +691,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
aliasName: entry.aliasName,
mobile: entry.mobile,
);
if (!mounted || _searchController.text.trim() != query) return;
if (!mounted || requestGeneration != _searchGeneration) return;
final customers = result['customers'] as List<Customer>;
final created = result['created'] == true;
@ -965,6 +987,7 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
child: TextField(
controller: _searchController,
focusNode: _fieldFocus,
selectAllOnFocus: true,
decoration: widget.dense
? InvoiceFormFieldMetrics.mergeDecoration(
context,
@ -1088,16 +1111,22 @@ class _CustomerComboboxWidgetState extends State<CustomerComboboxWidget> {
},
onChanged: (query) {
if (_suppressFieldNotifications) return;
final trimmed = query.trim();
if (trimmed.isEmpty && widget.selectedCustomer != null) {
widget.onCustomerChanged(null);
} else if (widget.selectedCustomer != null &&
trimmed != (widget.selectedCustomer?.name ?? '').trim()) {
widget.onCustomerChanged(null);
// In quick entry, text is a local draft and the invoice customer
// changes only after an explicit selection or successful create.
// Other uses keep their existing clear-on-edit behavior.
if (!widget.enableQuickCreateOnSubmit) {
final trimmed = query.trim();
if (widget.selectedCustomer != null &&
trimmed != (widget.selectedCustomer?.name ?? '').trim()) {
widget.onCustomerChanged(null);
}
}
_onSearchChanged(query);
_showDesktopOverlay();
},
// Keep focus while the asynchronous quick-resolve request is in
// flight. Selection itself decides when the field should unfocus.
onEditingComplete: () {},
onSubmitted: (_) => unawaited(_submitField()),
),
);
@ -1200,6 +1229,7 @@ class _CustomerPickerBottomSheetState
child: TextField(
controller: widget.searchController,
focusNode: _searchFocus,
selectAllOnFocus: true,
decoration: InputDecoration(
hintText: 'جست‌وجو در طرف حساب‌ها...',
prefixIcon: const Icon(Icons.search),

View file

@ -122,5 +122,37 @@ void main() {
CustomerSearchSubmitAction.search,
);
});
test('resolves a mobile directly even while generic search is stale', () {
expect(
resolveCustomerSearchSubmitAction(
input: '09171006219 موسوی',
loadedQuery: 'موسوی',
suggestionCount: 1,
hasMoreSuggestions: false,
navigatedByKeyboard: false,
isLoading: true,
quickCreateEnabled: true,
inputHasMobile: true,
),
CustomerSearchSubmitAction.quickCreate,
);
});
test('keeps explicit keyboard selection for a mobile entry', () {
expect(
resolveCustomerSearchSubmitAction(
input: '09171006219',
loadedQuery: '09171006219',
suggestionCount: 2,
hasMoreSuggestions: false,
navigatedByKeyboard: true,
isLoading: false,
quickCreateEnabled: true,
inputHasMobile: true,
),
CustomerSearchSubmitAction.selectSuggestion,
);
});
});
}