From 8fd881e0ea44e9953cf85feb11b11ca6173b85c6 Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 21 Oct 2026 15:13:35 +0330 Subject: [PATCH] improve bazdid 4 --- PROJECT_TREE.txt | 19 +- admin_panel/templates/admin_panel/base.html | 2 + .../templates/admin_panel/baseHesabdari.html | 2 + .../templates/admin_panel/entezar.html | 464 +++++++++----- admin_panel/views/customers.py | 457 +++++++++++--- admin_panel/visit_policy.py | 213 ++++++- docs/visit_hold_entezar.md | 228 +++++-- khatibi/settings.py | 2 +- tests/test_view_smoke.py | 113 ++++ tests/test_visit_policy.py | 572 +++++++++++++++++- 10 files changed, 1752 insertions(+), 320 deletions(-) diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index d010a73..87222d0 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -1,11 +1,11 @@ Project Root: khatibi -Generated At: 2026-10-20T17:07:57+00:00 -Python Files: 187 +Generated At: 2026-10-21T10:54:58+00:00 +Python Files: 188 Django Files: 12 JavaScript/TypeScript Files: 14 HTML Files: 58 -Total Files: 746 -Total Directories: 78 +Total Files: 754 +Total Directories: 79 khatibi ├── .cursor @@ -129,6 +129,7 @@ khatibi │ │ ├── 0076_alter_smstemplate_text_help.py │ │ ├── 0077_alter_smstemplate_text_balance_help.py │ │ ├── 0078_action_visit_covering_index.py +│ │ ├── 0079_customer_visit_hold.py │ │ └── __init__.py │ ├── models │ │ ├── __init__.py @@ -323,10 +324,18 @@ khatibi │ └── visit_policy.py ├── data9 │ ├── backup-20261020-180934.sqlite3 +│ ├── backup-20261020-213406.sqlite3 │ ├── db2 (2).sqlite32 │ ├── db2.sqlite32 │ ├── db222.sqlite3 -│ └── kt.sqlite3 +│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3 +│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-shm +│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-wal +│ ├── kt.sqlite3 +│ ├── kt.sqlite3-shm +│ └── kt.sqlite3-wal +├── docs +│ └── visit_hold_entezar.md ├── graphify-out │ ├── 2026-10-09 │ │ ├── .graphify_analysis.json diff --git a/admin_panel/templates/admin_panel/base.html b/admin_panel/templates/admin_panel/base.html index b86596d..f66821d 100644 --- a/admin_panel/templates/admin_panel/base.html +++ b/admin_panel/templates/admin_panel/base.html @@ -239,6 +239,8 @@ {% block bodyPage %} {% endblock %} + {# بستن
که بالاتر باز شده بود؛ پیش‌تر بسته نمی‌شد و HTML نامعتبر می‌شد. #} +
{% endblock %} diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py index 9d25ee0..dbe6d2f 100644 --- a/admin_panel/views/customers.py +++ b/admin_panel/views/customers.py @@ -19,19 +19,47 @@ from ..sms.phones import (normalize_primary_values, from ..sms.triggers import notify_payment_thanks, notify_welcome from ..sms.rules import customer_toggle_map from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED, - appointment_sources_map, last_visit_map, visit_due_at, - visit_due_date, visit_period_months) + appointment_sources_map, as_aware, + followups_on_date, last_visit_map, overdue_followups, + visit_due_at, visit_due_date, visit_period_months) -# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار -# قبلی صفحه برای کاربران حفظ شود. -ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination', +# نماهای صفحهٔ «انتظار» (فیلتر ?view=). +# پیش‌فرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماس‌های +# امروز). سایر نماها با کارت‌ها/دراپ‌داون «نوع قرار» در دسترس‌اند. +# ``today`` یک نمای **مستقل** است: پایهٔ آن «عقب‌افتاده بودن» نیست — مشتری +# سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود. +ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination', 'coordinated', 'all') -ENTEZAR_DEFAULT_VIEW = 'uncoordinated' -# حالت‌های مرتب‌سازی (?sort=): -# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس) -# newest ⇒ جدیدترین بازدید اول -# overdue ⇒ بیشترین تأخیر اول -ENTEZAR_SORTS = ('oldest', 'newest', 'overdue') +ENTEZAR_DEFAULT_VIEW = 'today' +# نمای «نیازمند پیگیری» — برای بررسی «آیا این نما فعال است؟» در قالب/ویو. +ENTEZAR_FOLLOWUP_VIEW = 'today' + +# تعداد ردیف در هر صفحه. فهرست کاری روزانه با یک نگاه مرور می‌شود، پس صفحه‌بندی +# دیرتر فعال شود. +ENTEZAR_PAGE_SIZE = 100 + +# فیلتر «میزان تأخیر» (?delay=). مرزها با «مدت تأخیر» (روز) مقایسه می‌شوند. +ENTEZAR_DELAYS = ('lt30', '30to90', '90to180', 'gt180') + +# ستون‌های قابل مرتب‌سازی: نام فیلد ⇒ جهت پیش‌فرضِ اولین کلیک. +# ``oldest``/``newest``/``overdue`` مقادیر قدیمی‌اند و برای سازگاری با +# بوکمارک‌های قبلی نگه داشته شده‌اند (به ``last_visit``/``overdue`` نگاشت می‌شوند). +ENTEZAR_SORT_COLUMNS = { + 'name': 'asc', + 'status': 'asc', + 'manual': 'desc', + 'scheduled': 'desc', + 'coordination': 'desc', + 'last_visit': 'asc', + 'overdue': 'desc', + 'phone': 'asc', + 'address': 'asc', +} +_ENTEZAR_LEGACY_SORTS = { + 'oldest': ('last_visit', 'asc'), + 'newest': ('last_visit', 'desc'), + 'overdue': ('overdue', 'desc'), +} from ._helpers import logger, _parse_jalali_date, _parse_phone_rows @@ -359,9 +387,26 @@ def entezar(request): view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower() if view not in ENTEZAR_VIEWS: view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیش‌فرض، بدون خطا - sort = (request.GET.get('sort') or 'oldest').strip().lower() - if sort not in ENTEZAR_SORTS: - sort = 'oldest' + delay = (request.GET.get('delay') or '').strip().lower() + if delay not in ENTEZAR_DELAYS: + delay = '' # '' یعنی بدون فیلتر تأخیر + + # مرتب‌سازی: ``?sort=<ستون>&dir=asc|desc``؛ مقادیر قدیمی هم پذیرفته می‌شوند. + # نکته: اگر ``dir`` صریح آمده باشد، مقدم است — وگرنه کلیک روی سرستون + # «تأخیر» با جهت صعودی، به‌خاطر نگاشت قدیمی، نادیده گرفته می‌شد. + raw_sort = (request.GET.get('sort') or '').strip().lower() + raw_dir = (request.GET.get('dir') or '').strip().lower() + if raw_sort in ENTEZAR_SORT_COLUMNS: + sort_field = raw_sort + if raw_dir in ('asc', 'desc'): + sort_dir = raw_dir + else: + sort_dir = 'desc' if raw_sort in _ENTEZAR_LEGACY_SORTS else \ + ENTEZAR_SORT_COLUMNS[raw_sort] + elif raw_sort in _ENTEZAR_LEGACY_SORTS: + sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS[raw_sort] + else: + sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS['oldest'] searchStatus = True full_path = request.get_full_path() @@ -370,32 +415,47 @@ def entezar(request): # دوره فقط یک بار خوانده می‌شود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر). period_months = visit_period_months() # مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند. - due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date() + current_tz = timezone.get_current_timezone() + today_date = now.astimezone(current_tz).date() + due_cut_date = due_cut.astimezone(current_tz).date() + + # تاریخ نمای «نیازمند پیگیری»: از تقویم شمسی می‌آید (``?fdate=1405/08/29``). + # خالی/نامعتبر ⇒ امروز. مقدار گذشته و آینده هر دو مجازند. + followup_date_raw = (request.GET.get('fdate') or '').strip() + followup_date = _parse_jalali_date(followup_date_raw) or today_date + followup_date_jalali = JalaliDate(followup_date).strftime('%Y/%m/%d') + is_followup_view = view == ENTEZAR_FOLLOWUP_VIEW visits = last_visit_map(now=now) sources = appointment_sources_map(now=now) + # جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی. + def _apply_search(queryset): + """فیلتر جست‌وجو؛ فیلد نامعتبر ⇒ بدون فیلتر (سازگاری با رفتار قبلی).""" + if not searchVal: + return queryset + if searchOn == 'address': + return queryset.filter(address__contains=searchVal) + if searchOn == 'name': + return queryset.filter(name_family__contains=searchVal) + if searchOn == 'phone': + return queryset.filter( + Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) + | Q(phone3__contains=searchVal) + ) + if searchOn == 'code': + return queryset.filter(idCode__contains=searchVal) + return queryset + + searchStatus = bool(searchVal) and searchOn in ( + 'address', 'name', 'phone', 'code') + + # پایهٔ فهرست انتظار: فقط مشتریانی که دوره‌شان گذشته (کارت‌های آمار). customers_objj = Customer.objects.filter( laghv_bazdid=False, name_family__isnull=False, installDate__lte=due_cut_date, ) - # جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی. - if searchVal: - if searchOn == 'address': - customers_objj = customers_objj.filter(address__contains=searchVal) - elif searchOn == 'name': - customers_objj = customers_objj.filter(name_family__contains=searchVal) - elif searchOn == 'phone': - customers_objj = customers_objj.filter( - Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) - | Q(phone3__contains=searchVal) - ) - elif searchOn == 'code': - customers_objj = customers_objj.filter(idCode__contains=searchVal) - else: - searchStatus = False - else: - searchStatus = False + customers_objj = _apply_search(customers_objj) # مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره. stale_ids = [ @@ -431,12 +491,79 @@ def entezar(request): 'coordinated': coordinated_ids, 'all': stale_ids, } + + # ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ---------- + # این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس + # گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود + # ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید). + today_followups = followups_on_date(today_date, now=now) + + # پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند. + # فقط در نمای پیگیری محاسبه می‌شود (کوئری اضافه برای بقیهٔ نماها لازم نیست). + overdue_map = overdue_followups(now=now, target_date=today_date) \ + if is_followup_view else {} + overdue_oldest = min(overdue_map.values()) if overdue_map else None + + # فیلتر «فقط عقب‌افتاده‌ها» (?missed=1) — همان نما با منبعِ دیگر. + show_missed = is_followup_view and \ + (request.GET.get('missed') or '').strip().lower() in ('1', 'true', 'on') + + if is_followup_view: + if show_missed: + # تاریخِ موعدِ ازدست‌رفته به‌صورت datetime تا با بقیهٔ مسیرها یکسان باشد. + followups = { + cid: {SOURCE_COORDINATION: as_aware(day)} + for cid, day in overdue_map.items() + } + elif followup_date == today_date: + followups = today_followups + else: + followups = followups_on_date(followup_date, now=now) + else: + followups = {} + + def _followup_ids(mapping): + """شناسه‌های واجد شرایط (فعال/لغو‌نشده/دارای نام + جست‌وجو) با ترتیب زمانی.""" + if not mapping: + return [] + eligible = _apply_search( + Customer.objects.filter( + id__in=list(mapping), + is_active=True, + laghv_bazdid=False, + name_family__isnull=False, + ) + ) + allowed = set(eligible.values_list('id', flat=True)) + return sorted( + (cid for cid in mapping if cid in allowed), + key=lambda cid: (min(mapping[cid].values()), cid), + ) + + followup_ids = _followup_ids(followups) if is_followup_view else [] + # شمارنده‌ها از همان مسیرِ فهرست می‌آیند تا «عدد هشدار = تعداد ردیف‌ها» بماند + # (مشتری غیرفعال/لغو‌شده/بی‌نام یا فیلترشده شمرده نمی‌شود). + today_followup_count = len(_followup_ids(today_followups)) + overdue_followup_count = len(_followup_ids( + {cid: {SOURCE_COORDINATION: as_aware(day)} for cid, day in overdue_map.items()} + )) + selected_map[ENTEZAR_FOLLOWUP_VIEW] = followup_ids + selected = selected_map[view] + # برای نمای پیگیری، فهرست مشتریان از همان شناسه‌های تأییدشده می‌آید + # (ممکن است در «عقب‌افتاده» نباشند). + if is_followup_view: + customers_objj = _apply_search( + Customer.objects.filter(id__in=followup_ids) + ) + # «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز). # مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره. # این‌طور مشتریِ تازه‌معوق هم در بازهٔ کوتاه قرار می‌گیرد و عدد معنا پیدا می‌کند. - today = now.astimezone(timezone.get_current_timezone()).date() + # محاسبه روی کلِ نمای جاری انجام می‌شود تا شمارنده‌های بازه‌ها با فیلتر + # «میزان تأخیر» هم‌خوان باشند (اول نمای جاری، بعد فیلتر تأخیر). + today = today_date overdue_days = {} for customer_id in selected: anchor = visits.get(customer_id) @@ -448,42 +575,7 @@ def entezar(request): base = due_cut_date overdue_days[customer_id] = max((today - base).days, 0) - # مرتب‌سازی: - # oldest ⇒ قدیمی‌ترین بازدید اول (اولویت کارشناس) - # newest ⇒ جدیدترین بازدید اول - # overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده) - if sort == 'overdue': - ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid)) - customers_objj = customers_objj.filter(id__in=ordered_ids) - if ordered_ids: - # ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظه‌ای). - ordering = Case( - *[When(id=cid, then=Value(index)) - for index, cid in enumerate(ordered_ids)], - output_field=models.IntegerField(), - ) - customers_objj = customers_objj.order_by(ordering) - else: - customers_objj = customers_objj.order_by('id') - else: - # مرتب‌سازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکن‌القدمت. - ordered_ids = sorted( - selected, - key=lambda cid: ( - (visits.get(cid) or due_cut), - cid, - ), - reverse=(sort == 'newest'), - ) - customers_objj = customers_objj.filter(id__in=ordered_ids) - ordering = Case( - *[When(id=cid, then=Value(index)) - for index, cid in enumerate(ordered_ids)], - output_field=models.IntegerField(), - ) - customers_objj = customers_objj.order_by(ordering) - - # آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری). + # آمار «تأخیر» روی نمای جاری (قبل از اعمال فیلتر تأخیر). buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0} for days in overdue_days.values(): if days < 30: @@ -495,17 +587,110 @@ def entezar(request): else: buckets['gt180'] += 1 + # اعمال فیلتر «میزان تأخیر». + delay_ranges = { + 'lt30': (0, 30), + '30to90': (30, 90), + '90to180': (90, 180), + 'gt180': (180, None), + } + # فیلتر «میزان تأخیر» فقط برای نمای «عقب‌افتاده» معنا دارد؛ در نمای + # «پیگیری امروز» مشتری ممکن است اصلاً تأخیری نداشته باشد. + if delay and not is_followup_view: + low, high = delay_ranges[delay] + selected = [ + cid for cid in selected + if overdue_days.get(cid, 0) >= low + and (high is None or overdue_days.get(cid, 0) < high) + ] + + # ---------- مرتب‌سازی (روی همهٔ ستون‌ها) ---------- + # یک کوئری سبک برای فیلدهای Customer که برای مرتب‌سازی لازم است. + info = { + row['id']: row + for row in Customer.objects.filter(id__in=selected).values( + 'id', 'name_family', 'phone1', 'address') + } if selected else {} + + def _source_date(cid, key): + return (sources.get(cid) or {}).get(key) + + def _sort_value(cid): + """مقدار مرتب‌سازی برای هر ستون (تاریخ‌ها aware یا None).""" + if sort_field == 'name': + return (info.get(cid, {}).get('name_family') or '') + if sort_field == 'phone': + return (info.get(cid, {}).get('phone1') or '') + if sort_field == 'address': + return (info.get(cid, {}).get('address') or '') + if sort_field == 'status': + # ترتیب منطقی: بدون قرار ← قرار دستی ← بازدید ← هماهنگی ← چندقراره + entry = sources.get(cid) or {} + if not entry: + return 0 + if len(entry) > 1: + return 4 + if SOURCE_MANUAL in entry: + return 1 + if SOURCE_SCHEDULED in entry: + return 2 + return 3 + if sort_field == 'manual': + return _source_date(cid, SOURCE_MANUAL) + if sort_field == 'scheduled': + return _source_date(cid, SOURCE_SCHEDULED) + if sort_field == 'coordination': + return _source_date(cid, SOURCE_COORDINATION) + if sort_field == 'overdue': + return overdue_days.get(cid, 0) + # last_visit: مشتریِ بدون بازدید، قدیمی‌ترین حساب می‌شود. + return visits.get(cid) or due_cut + + if selected: + if is_followup_view and not raw_sort: + # در نمای پیگیری، ترتیب طبیعی «زودترین موعد پیگیری» است + # (ترتیب از ``_followup_ids`` آمده) — نه قدیمی‌ترین بازدید. + ordered_ids = list(selected) + else: + values = {cid: _sort_value(cid) for cid in selected} + with_value = [cid for cid in selected if values[cid] is not None] + without_value = [cid for cid in selected if values[cid] is None] + with_value.sort(key=lambda cid: (values[cid], cid), + reverse=(sort_dir == 'desc')) + # مقادیر خالی همیشه آخر می‌آیند (مستقل از جهت مرتب‌سازی) تا فهرست + # با ستون‌های «قرار دستی/بازدید/هماهنگی» بی‌معنا نشود. + ordered_ids = with_value + without_value + else: + ordered_ids = [] + + customers_objj = customers_objj.filter(id__in=ordered_ids) + if ordered_ids: + ordering = Case( + *[When(id=cid, then=Value(index)) + for index, cid in enumerate(ordered_ids)], + output_field=models.IntegerField(), + ) + customers_objj = customers_objj.order_by(ordering) + else: + customers_objj = customers_objj.order_by('id') + counts = { 'total': len(stale_ids), 'uncoordinated': len(selected_map['uncoordinated']), - # عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود؛ - # چون یک مشتری می‌تواند چند منبع داشته باشد، جمع کارت‌ها ممکن است - # از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده می‌شود. + # عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود. + # یک مشتری می‌تواند چند منبع داشته باشد، پس جمع سه منبع می‌تواند از + # «تعیین تکلیف شده» بیشتر شود؛ جزئیات در دراپ‌داون «نوع قرار» است. 'manual': len(selected_map['manual']), 'scheduled': len(selected_map['scheduled']), 'coordination': len(selected_map['coordination']), 'coordinated': len(selected_map['coordinated']), + # کارت «نیازمند پیگیری امروز» — همیشه عدد امروز را نشان می‌دهد، + # مستقل از تاریخی که کاربر در تقویم انتخاب کرده است. + 'followup_today': today_followup_count, + # پیگیری‌های موعدگذشته که هنوز تعیین تکلیف نشده‌اند (هشدار قرمز). + 'followup_overdue': overdue_followup_count, 'selected': len(selected), + 'view_total': len(selected_map[view]), 'buckets': buckets, # برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»). 'period_months': period_months, @@ -513,20 +698,118 @@ def entezar(request): try: entezar_bazdid = CacheTable.objects.get(key='9mahbazdid') - # کارت داشبورد = «کارِ فوری» = بدون قرار. + # کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده. entezar_bazdid.value = counts['uncoordinated'] entezar_bazdid.save() except CacheTable.DoesNotExist: pass - paginator = Paginator(customers_objj, 20) + paginator = Paginator(customers_objj, ENTEZAR_PAGE_SIZE) page_number = request.GET.get('page', None) customerHa = paginator.get_page(page_number) + # لینک صفحه‌بندی با حفظ همهٔ فیلترهای فعلی. + def _page_link(number): + params = request.GET.copy() + params['page'] = number + return '?' + params.urlencode() + + page_prev = _page_link(customerHa.previous_page_number()) if customerHa.has_previous() else '' + page_next = _page_link(customerHa.next_page_number()) if customerHa.has_next() else '' + + # ---------- لینک‌های حفظ‌کنندهٔ فیلترها ---------- + def _qs(**overrides): + """پارامترهای فعلی + تغییرهای داده‌شده (بدون page). None ⇒ حذف.""" + params = request.GET.copy() + params.pop('page', None) + for key, value in overrides.items(): + if value is None: + params.pop(key, None) + else: + params[key] = value + query = params.urlencode() + return '?' + query if query else '?' + + view_links = {name: _qs(view=name) for name in ENTEZAR_VIEWS} + # کارت «نیازمند پیگیری امروز» همیشه به امروز می‌رود (تاریخ انتخابی پاک می‌شود). + view_links[ENTEZAR_FOLLOWUP_VIEW] = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None) + + # ---------- ناوبری تقویم در نمای پیگیری ---------- + def _followup_link(target_date): + return _qs(view=ENTEZAR_FOLLOWUP_VIEW, + fdate=JalaliDate(target_date).strftime('%Y/%m/%d')) + + followup_nav = { + 'prev': _followup_link(followup_date - datetime.timedelta(days=1)), + 'next': _followup_link(followup_date + datetime.timedelta(days=1)), + 'today': _followup_link(today_date), + 'is_today': followup_date == today_date, + } + # لینک هشدار «عقب‌افتاده‌ها» و لینک بازگشت از آن حالت. + followup_overdue_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=1) + followup_ontime_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=None) + + delay_links = {'ez_all': _qs(delay=None)} + delay_links.update({name: _qs(delay=name) for name in ENTEZAR_DELAYS}) + sort_links = {} + for column, default_dir in ENTEZAR_SORT_COLUMNS.items(): + # کلیک دوباره روی همان ستون، جهت را برمی‌گرداند. + next_dir = ('desc' if sort_dir == 'asc' else 'asc') \ + if sort_field == column else default_dir + sort_links[column] = _qs(sort=column, dir=next_dir) + + # سرستون‌های مرتب‌شدنی (به همان ترتیب ستون‌های جدول). + # در نمای «نیازمند پیگیری امروز» ستون «تأخیر» نمایش داده **نمی‌شود**: + # این فهرست دربارهٔ «موعد امروز» است، نه عقب‌افتادگی بازدید. + columns = [ + ('name', 'نام و نام خانوادگی', 21), + ('status', 'وضعیت قرار', 12), + ('manual', 'قرار دستی', 10), + ('scheduled', 'بازدید برنامه‌ریزی‌شده', 10), + ('coordination', 'هماهنگی', 10), + ('last_visit', 'آخرین بازدید', 9), + ('overdue', 'تأخیر', 8), + ('phone', 'تلفن', 9), + ('address', 'آدرس', 11), + ] + if is_followup_view: + hidden_width = next(width for key, _, width in columns if key == 'overdue') + columns = [col for col in columns if col[0] != 'overdue'] + # عرض ستون حذف‌شده به نام اضافه می‌شود تا مجموع همچنان ۱۰۰٪ بماند. + columns[0] = (columns[0][0], columns[0][1], columns[0][2] + hidden_width) + + table_columns = [{'key': key, 'label': label, 'width': width} + for key, label, width in columns] + hidden_columns = ['overdue'] if is_followup_view else [] + + sort_header = [ + { + 'key': column, + 'label': label, + 'link': sort_links[column], + 'active': sort_field == column, + 'dir': sort_dir if sort_field == column else '', + } + for column, label in ((key, label) for key, label, _ in columns) + ] + # لینک «حذف همهٔ فیلترها»: نمای پیش‌فرض (امروز)، بدون تأخیر/جست‌وجو/مرتب‌سازی. + clear_link = '?view=' + ENTEZAR_DEFAULT_VIEW + has_filters = bool( + delay or searchVal or sort_field != 'last_visit' + or sort_dir != 'asc' + # تاریخِ انتخاب‌شده یا حالت «فقط عقب‌افتاده‌ها» هم فیلتر است. + or (is_followup_view and followup_date != today_date) + or show_missed + ) + # اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر. appointment_rows = {} for customer in customerHa.object_list: - entry = sources.get(customer.id) or {} + entry = dict(sources.get(customer.id) or {}) + # در نمای پیگیری، تاریخ همان روز ملاک نمایش است (حتی اگر گذشته باشد و + # در ``appointment_sources_map`` که فقط آینده را می‌بیند، نباشد). + followup_entry = followups.get(customer.id) or {} + entry.update(followup_entry) flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED, SOURCE_COORDINATION) if key in entry] appointment_rows[customer.id] = { @@ -537,14 +820,36 @@ def entezar(request): 'last_visit': visits.get(customer.id), # تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد. 'source_count': len(flags), + # تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»). + 'followup_at': (min(followup_entry.values()) + if followup_entry else None), } return render(request, 'admin_panel/entezar.html', { 'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn': searchOn, 'view': view, 'counts': counts, 'appointment_rows': appointment_rows, - 'sort': sort, 'sorts': ENTEZAR_SORTS, + 'view_links': view_links, 'delay_links': delay_links, + 'sort_links': sort_links, 'sort_header': sort_header, + 'clear_link': clear_link, + 'has_filters': has_filters, + 'delay': delay, 'delays': ENTEZAR_DELAYS, + 'sort': sort_field, 'sort_dir': sort_dir, + 'sort_columns': ENTEZAR_SORT_COLUMNS, + 'table_columns': table_columns, + 'hidden_columns': hidden_columns, + 'column_count': len(table_columns), + 'page_prev': page_prev, 'page_next': page_next, 'views': ENTEZAR_VIEWS, + # نمای «نیازمند پیگیری» + 'is_followup_view': is_followup_view, + 'followup_view': ENTEZAR_FOLLOWUP_VIEW, + 'followup_date': followup_date, + 'followup_date_jalali': followup_date_jalali, + 'followup_nav': followup_nav, + 'show_missed': show_missed, + 'followup_overdue_link': followup_overdue_link, + 'followup_ontime_link': followup_ontime_link, }) diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py index e804b95..3b789fe 100644 --- a/admin_panel/visit_policy.py +++ b/admin_panel/visit_policy.py @@ -402,33 +402,24 @@ def coordination_holds_map(*, now=None): ) -def manual_holds_map(*, now=None): - """قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**. +def _valid_manual_holds(holds): + """اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست». - قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که: + ورودی ``{customer_id: visit_hold_until}`` و خروجی همان نگاشت، فقط برای + قرارهایی که معتبرند: - ۱. تاریخش از حالا جلوتر باشد، - ۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد، - ۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام + ۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد، + ۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است. - بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛ - بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار» - می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است. + بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند. - نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛ - و کهنه‌ماندن ستون هیچ اثر مخربی ندارد. + این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم + :func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ + واحد** استفاده کنند و دو تعریف واگرا شکل نگیرد. """ - now = _now(now) - rows = ( - Customer.objects.filter(visit_hold_until__gt=now) - .values_list("id", "visit_hold_until") - .iterator() - ) - holds = {customer_id: as_aware(until) for customer_id, until in rows} if not holds: return {} - customer_ids = list(holds) # مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با @@ -469,6 +460,190 @@ def manual_holds_map(*, now=None): return valid +def manual_holds_map(*, now=None): + """قرارهای دستی **آینده** (ستون ``visit_hold_until``) — با اعتبارسنجی. + + برای فهرست انتظار و آمار؛ قاعدهٔ اعتبار در :func:`_valid_manual_holds`. + """ + now = _now(now) + rows = ( + Customer.objects.filter(visit_hold_until__gt=now) + .values_list("id", "visit_hold_until") + .iterator() + ) + return _valid_manual_holds( + {customer_id: as_aware(until) for customer_id, until in rows} + ) + + +# --------------------------------------------------------------------------- +# «نیازمند پیگیری» در یک روز مشخص +# --------------------------------------------------------------------------- +def _local_day_bounds(target_date): + """بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی (aware).""" + current_tz = timezone.get_current_timezone() + start = datetime.combine(target_date, time.min) + end = datetime.combine(target_date, time.max) + try: + return ( + timezone.make_aware(start, current_tz), + timezone.make_aware(end, current_tz), + ) + except Exception: + return ( + timezone.make_aware(start, current_tz, is_dst=False), + timezone.make_aware(end, current_tz, is_dst=False), + ) + + +def _pending_visit_customer_ids(*, since): + """مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند. + + قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس + بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since`` + (آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود. + + چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی + (backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد. + """ + return set( + Action.objects.filter( + actionType=Action.ActionType.BAZDID, + status=Action.Status.WAITE, + ) + .filter( + models.Q(actionDateTime__gte=since) + | models.Q(actionDateTime__isnull=True, finishDateTime__gte=since) + ) + .values_list("customer_id", flat=True) + ) + + +def followups_on_date(target_date, *, now=None): + """مشتریانِ «نیازمند پیگیری» در یک تاریخ مشخص (تقویم شمسی/میلادی). + + «نیازمند پیگیری» یعنی موعدی که با مشتری توافق شده **همان روز** است: + + * **قرار دستی** — ``Customer.visit_hold_until`` که تاریخ آن همان روز است + (با همان اعتبارسنجی :func:`_valid_manual_holds`). + * **هماهنگی** — اقدام ``hamahangi`` با ``actionDateTime`` (وگرنه + ``finishDateTime``) در همان روز، با وضعیت ``waite`` یا ``finish``. + + تفاوت با :func:`appointment_sources_map`: آن تابع فقط **آینده** را می‌بیند + (برای تصمیم «خارج از فهرست انتظار»)، ولی این یکی **هر روزی** — گذشته، + امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود. + + خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد. + مشتریانی که قرار **بازدید** دارند در خروجی نیستند. + """ + day_start, day_end = _local_day_bounds(target_date) + + result = {} + + # ۱) قرارهای دستی همان روز (مستقل از گذشته/آینده بودن). + rows = ( + Customer.objects.filter( + visit_hold_until__gte=day_start, visit_hold_until__lte=day_end, + ) + .values_list("id", "visit_hold_until") + .iterator() + ) + for customer_id, until in _valid_manual_holds( + {cid: as_aware(when) for cid, when in rows} + ).items(): + result.setdefault(customer_id, {})[SOURCE_MANUAL] = until + + # ۲) اقدام‌های «هماهنگی» همان روز. + coordination_rows = ( + Action.objects.filter( + actionType=Action.ActionType.HAMAHANGI, + status__in=(Action.Status.WAITE, Action.Status.FINISH), + ) + .filter( + models.Q(actionDateTime__gte=day_start, actionDateTime__lte=day_end) + | models.Q( + actionDateTime__isnull=True, + finishDateTime__gte=day_start, + finishDateTime__lte=day_end, + ) + ) + .values_list("customer_id", "actionDateTime", "finishDateTime") + .iterator() + ) + for customer_id, action_dt, finish_dt in coordination_rows: + when = as_aware(action_dt) or as_aware(finish_dt) + if when is None: + continue + bucket = result.setdefault(customer_id, {}) + current = bucket.get(SOURCE_COORDINATION) + if current is None or when < current: + bucket[SOURCE_COORDINATION] = when + + # ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است. + if result: + for customer_id in _pending_visit_customer_ids(since=day_start): + result.pop(customer_id, None) + + return result + + +def overdue_followups(*, now=None, target_date=None): + """پیگیری‌های **عقب‌افتاده**: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند. + + «عقب‌افتاده» یعنی برای هر مشتری، **آخرین** اقدام هماهنگی (``waite``/``finish``) + تاریخی پیش از امروز دارد؛ یعنی موعد توافق‌شده گذشته و هیچ موعد تازه‌ای + جایگزینش نشده است. + + قواعد: + + * اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست + (موعدش تازه است). + * اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ + :func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»). + * هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود. + + خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم + بتوانیم کاربر را به همان روز ببریم. + """ + now = _now(now) + today = target_date or now.astimezone(timezone.get_current_timezone()).date() + + rows = ( + Action.objects.filter( + actionType=Action.ActionType.HAMAHANGI, + status__in=(Action.Status.WAITE, Action.Status.FINISH), + ) + .values_list("customer_id", "actionDateTime", "finishDateTime") + .iterator() + ) + + # آخرین موعد هر مشتری (تاریخ، نه لحظه). + latest = {} + for customer_id, action_dt, finish_dt in rows: + when = as_aware(action_dt) or as_aware(finish_dt) + if when is None: + continue + day = when.astimezone(timezone.get_current_timezone()).date() + current = latest.get(customer_id) + if current is None or day > current: + latest[customer_id] = day + + overdue = {cid: day for cid, day in latest.items() if day < today} + if not overdue: + return {} + + # حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز). + # «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده + # تکلیف مشتری را روشن کرده است. + pending_visits = _pending_visit_customer_ids( + since=_local_day_bounds(min(overdue.values()))[0] + ) + for customer_id in pending_visits: + overdue.pop(customer_id, None) + + return overdue + + def appointment_sources_map(*, now=None): """نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده). diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md index f08d6ea..9db51e7 100644 --- a/docs/visit_hold_entezar.md +++ b/docs/visit_hold_entezar.md @@ -273,14 +273,15 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید) | URL | نمایش | شامل | |---|---|---| -| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = بدون قرار** (رفتار قبلی) | بدون هیچ منبع | +| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = نیازمند پیگیری امروز** | موعد تماس‌های امروز | +| `/panel/entezar/?view=today` | نیازمند پیگیری امروز (یا تاریخ `fdate`) | موعد تماس‌های آن روز | | `?view=uncoordinated` | بدون قرار | بدون هیچ منبع | | `?view=manual` | قرار دستی | هر مشتری با `manual` | | `?view=scheduled` | بازدید برنامه‌ریزی‌شده | هر مشتری با `scheduled` | | `?view=coordination` | هماهنگی | هر مشتری با `coordination` | | `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع | | `?view=all` | همهٔ معوق‌ها | همه | -| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | بدون هیچ منبع | +| مقدار نامعتبر | برگشت به پیش‌فرض (`today`)، **بدون خطا** | موعد تماس‌های امروز | چون یک مشتری می‌تواند چند منبع داشته باشد، در چند فیلتر دیده می‌شود؛ اما در `?view=coordinated` فقط **یک‌بار** شمرده می‌شود. به همین دلیل جمع سه کارت منبع @@ -380,48 +381,180 @@ python manage.py test tests ## ۱. نوار آمار (بالای صفحه) -پنج کارت که هر کدام هم **عدد** را نشان می‌دهد و هم **لینک فیلتر** است: +چهار کارت. عنوان‌ها با **زبان کسب‌وکار** انتخاب شده‌اند، نه زبان دیتابیس. هر کارت +هم عدد را نشان می‌دهد و هم **لینک فیلتر** است: -| کارت | چه چیزی را نشان می‌دهد | +| کارت | معنا | توضیح زیر عدد | +|---|---|---| +| **نیازمند پیگیری امروز** | موعد تماسشان **امروز** است و بازدید ندارند — فهرست کارِ امروز | موعد تماسشان امروز است — بازدید ندارند | +| **همهٔ مشتریان عقب‌افتاده** | همه، با هر وضعیت پیگیری | با هر وضعیت پیگیری | +| **تعیین تکلیف نشده** | هنوز هیچ وقت بازدید/پیگیری ندارند | هنوز وقت بازدید تعیین نشده — نیاز به تماس | +| **تعیین تکلیف شده** | وقت بعدی‌شان مشخص است | وقت بعدی‌شان مشخص است | + +> **چرا «مشتری» و نه «بازدید»؟** عددِ کارت دوم تعداد **مشتری** است، نه تعداد +> بازدید. یک مشتری می‌تواند ده‌ها بازدید گذشته داشته باشد ولی فقط **یک بار** +> در این فهرست شمرده شود. + +### تفکیک نوع قرار — در دراپ‌داون، نه روی کارت + +کارت‌ها **عمداً ساده** نگه داشته شده‌اند: فقط عنوان، عدد و یک توضیح. جزئیات سه +نوع قرار (تعویق / بازدید / پیگیری) روی کارت چاپ **نمی‌شود**؛ چون: + +- شش عدد روی کارت‌ها باعث سردرگمی می‌شد (بازخورد کاربران). +- ارتفاع کارت‌ها نامساوی و چیدمان موبایل به‌هم می‌ریخت. +- همان اطلاعات از دراپ‌داون **«نوع قرار»** در نوار فیلتر در دسترس است. + +> جمع سه منبع می‌تواند از عدد «تعیین تکلیف شده» بیشتر باشد، چون یک مشتری +> می‌تواند چند منبع قرار داشته باشد؛ در `?view=coordinated` هر مشتری فقط +> **یک‌بار** شمرده می‌شود. + +## ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه** + +> **پیش‌فرض:** با باز کردن `/panel/entezar/` (بدون پارامتر)، همین نما نمایش داده +> می‌شود؛ کاربر بلافاصله فهرست کارِ امروز را می‌بیند. + +فهرست کاریِ روزانهٔ کارشناس: **با چه کسانی امروز باید تماس بگیریم؟** + +### این فهرست از کجا می‌آید؟ + +دو منبع، هر دو با شرط «موعد = همان روز»: + +| منبع | تعریف | |---|---| -| کل معوق | همهٔ مشتریان عقب‌افتاده (با قرار و بدون قرار) | -| بدون قرار | کسانی که هیچ قراری ندارند — **کار عملیاتی امروز** | -| با قرار | قرار هماهنگی یا بازدید برنامه‌ریزی‌شده دارند | -| قرار هماهنگی | قرار دستی ثبت‌شده روی مشتری | -| بازدید برنامه‌ریزی‌شده | بازدید/هماهنگیِ در جریان از نوع `Action` | +| **هماهنگی** | اقدام `hamahangi` با `status` در (`waite`/`finish`) و `actionDateTime` (وگرنه `finishDateTime`) در همان روز | +| **قرار دستی** | `Customer.visit_hold_until` که تاریخش همان روز است (با همان اعتبارسنجی «ستون، مرجع نیست») | -کارت فعال با حاشیهٔ آبی مشخص می‌شود. +### قاعدهٔ قطعی: «بازدید» اینجا نمی‌آید -## ۲. تفکیک مدت تأخیر +> اگر برای مشتری **قرار بازدید** (`bazdid + waite`) ثبت شده باشد، **هرگز** در این +> فهرست نمایش داده نمی‌شود — حتی اگر برای همان روز هم موعد پیگیری داشته باشد. -زیر نوار آمار، تعداد مشتریان بر اساس **مدت عقب‌افتادگی** نمایش داده می‌شود: +دلیلش روشن است: وقتی پرسنل برای بازدید می‌آید، تماس تلفنی لازم نیست. -- کمتر از ۳۰ روز +**استثنای مهم:** بازدیدهای بسیار قدیمی که هرگز بسته نشده‌اند (backlog) قرار فعال +محسوب نمی‌شوند؛ وگرنه یک رکورد کهنه می‌توانست مشتری را برای همیشه از فهرست +پنهان کند. شرط دقیق: بازدیدِ در انتظار با تاریخ **≥ آغاز روز مورد نظر**. + +### تفاوت با فهرست «عقب‌افتاده» + +| | «نیازمند پیگیری» | «عقب‌افتاده» | +|---|---|---| +| شرط ورود | موعد تماس = آن روز | از آخرین بازدید بیش از دوره گذشته | +| مشتری سالم | ✅ اگر قرار تماس داشته باشد می‌آید | ❌ نمی‌آید | +| شرط `installDate` | ندارد | دارد | +| ترتیب پیش‌فرض | زودترین ساعت موعد | قدیمی‌ترین بازدید | + +### تقویم شمسی (گذشته و آینده) + +نوار تاریخ فقط در این نما ظاهر می‌شود و شامل این‌هاست: + +- **تقویم شمسی** (`pDatepicker`) برای انتخاب هر روزی +- دکمه‌های **‹ روز قبل** و **روز بعد ›** +- دکمهٔ **بازگشت به امروز** (اگر تاریخ انتخابی امروز نباشد) + +``` +/panel/entezar/?view=today امروز +/panel/entezar/?view=today&fdate=1405/07/28 روز دیگر (شمسی) +``` + +- مقدار نامعتبر/خالی ⇒ **امروز** (بدون خطا). +- عدد کارت «نیازمند پیگیری امروز» **همیشه عدد امروز** را نشان می‌دهد، حتی وقتی + در تقویم روز دیگری را می‌بینید. +- فیلتر «میزان تأخیر» و تفکیک آن در این نما **پنهان** می‌شود (مشتری این فهرست + ممکن است اصلاً تأخیری نداشته باشد)؛ اما جست‌وجو و مرتب‌سازی فعال است. + +### ستون «تأخیر» در این نما نمایش داده نمی‌شود + +این فهرست دربارهٔ «موعد تماس امروز» است، نه عقب‌افتادگی بازدید؛ پس ستون «تأخیر» +فقط در سایر نماها دیده می‌شود. عرض آن (۸٪) به ستون «نام» اضافه می‌شود تا مجموع +عرض ستون‌ها همچنان دقیقاً ۱۰۰٪ بماند (۸ ستون به‌جای ۹). + +### هشدار «پیگیری‌های عقب‌افتاده» + +اگر موعد پیگیری مشتریانی گذشته باشد و هنوز تعیین تکلیف نشده باشند، زیر نوار تاریخ +یک هشدار مینیمال قرمز با نقطهٔ کوچک نمایش داده می‌شود: + +``` +● 72 مورد نیازمند پیگیری از روزهای قبل باقی مانده +``` + +قواعد محاسبه (`visit_policy.overdue_followups`): + +- مبنای هر مشتری، **آخرین** اقدام هماهنگی (`waite`/`finish`) است. +- اگر آن تاریخ **پیش از امروز** باشد ⇒ عقب‌افتاده. +- اگر مشتری هماهنگیِ امروز یا آینده داشته باشد ⇒ عقب‌افتاده **نیست** + (یعنی تمدید شده و از فهرست بیرون می‌رود). +- اگر برایش **قرار بازدید** ثبت شده باشد ⇒ حذف می‌شود (همان قاعدهٔ «بازدید گذاشته + شده ⇒ تماس لازم نیست»). +- هماهنگیِ لغوشده (`cancell`) اصلاً دیده نمی‌شود. + +کلیک روی هشدار، همان فهرست را باز می‌کند: + +``` +/panel/entezar/?view=today&missed=1 +``` + +در این حالت یک چیپ قرمز «نمایش N پیگیریِ عقب‌افتاده ✕ بازگشت به امروز» نشان داده +می‌شود و هشدار پنهان می‌گردد. عدد هشدار **دقیقاً** برابر تعداد ردیف‌های همین +فهرست است (هر دو از یک مسیر محاسبه می‌شوند). + +## ۲. فیلترها (نوار زیر کارت‌ها) + +یک ردیف فشرده با چهار کنترل؛ همه با هم ترکیب‌پذیرند و در URL حفظ می‌شوند. + +| کنترل | پارامتر | مقادیر | +|---|---|---| +| نوع قرار | `view` | `all` · `uncoordinated` · `coordinated` · `manual` · `scheduled` · `coordination` | +| میزان تأخیر | `delay` | `lt30` · `30to90` · `90to180` · `gt180` (خالی = همه) | +| مرتب‌سازی | `sort` + `dir` | `last_visit` · `overdue` · `name` · `status` · `coordination` (و هر ستون دیگر) | +| جست‌وجو | `searchVal` + `searchOn` | `name` · `phone` · `code` · `address` | + +- تغییر هر دراپ‌داون ⇒ فرم **خودکار** ارسال می‌شود. +- دکمهٔ **✕ حذف فیلترها** فقط وقتی فیلتری فعال است ظاهر می‌شود و همه را به + حالت پیش‌فرض برمی‌گرداند. +- **کارت‌ها هیچ‌وقت با فیلتر تغییر نمی‌کنند** — همیشه کل جامعه را نشان می‌دهند. + فقط فهرست زیرشان فیلتر می‌شود. + +## ۳. تفکیک مدت تأخیر (کلیک‌پذیر) + +زیر نوار فیلتر، تعداد مشتریان بر اساس **مدت عقب‌افتادگی** در **نمای جاری** +نمایش داده می‌شود و هر عدد **لینک فیلتر** است: + +- تا ۳۰ روز - ۳۰ تا ۹۰ روز - ۹۰ تا ۱۸۰ روز - بیش از ۶ ماه -**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب (پیش‌فرض ۹ ماه). -برای مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بی‌معنا نشود. -یعنی «۳ ماه تأخیر» یعنی «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید». +**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب. برای +مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بی‌معنا نشود. +یعنی «۳ ماه تأخیر» = «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید». -## ۳. مرتب‌سازی +> جمع این چهار عدد همیشه برابر تعداد ردیف‌های نمای جاری است (تست‌شده). -سه حالت اختیاری (پارامتر `sort` در URL، قابل بوکمارک): +## ۴. مرتب‌سازی (ستون‌ها و دراپ‌داون) -| مقدار | ترتیب | کاربرد | -|---|---|---| -| `oldest` (پیش‌فرض) | قدیمی‌ترین بازدید اول | اولویت کارشناس | -| `overdue` | بیشترین تأخیر اول | ببینیم چه کسی از همه دیرتر است | -| `newest` | جدیدترین بازدید اول | بررسی موارد تازه‌معوق | +**هر ۹ ستون** با کلیک روی سرستون مرتب می‌شوند؛ کلیک دوباره جهت را برمی‌گرداند: -ترتیب بر مبنای **تاریخ مؤثر بازدید از `Action`** است، نه ستون -`last_bazdid_dt` (که ممکن است کهنه باشد). برای گروهِ بدون بازدید، مبنای -ترتیب مقطع دوره است. +| ستون | مبنا | +|---|---| +| نام و نام خانوادگی | الفبایی | +| وضعیت قرار | بدون قرار ← تعویق ← بازدید ← پیگیری ← چندقراره | +| قرار دستی | تاریخ قرار (خالی‌ها آخر) | +| بازدید برنامه‌ریزی‌شده | تاریخ قرار (خالی‌ها آخر) | +| هماهنگی | تاریخ پیگیری (خالی‌ها آخر) | +| آخرین بازدید | تاریخ مؤثر بازدید از `Action` | +| تأخیر | تعداد روز تأخیر | +| تلفن | مقدار `phone1` | +| آدرس | الفبایی | -## ۴. جست‌وجو (تکمیل‌شده) +- سرستون فعال با فلش **▲/▼** و ستون‌های دیگر با **⇅** مشخص می‌شوند. +- **مقادیر خالی همیشه آخر می‌آیند**، مستقل از جهت — وگرنه مرتب‌سازی ستون‌های + قرار، فهرست را بی‌معنا می‌کرد. +- دراپ‌داون «مرتب‌سازی» چند گزینهٔ پرکاربرد را کوتاه می‌کند. +- مقادیر قدیمی `sort=oldest|newest|overdue` همچنان کار می‌کنند (سازگاری با + بوکمارک‌های قبلی). -پیش‌تر فقط جست‌وجو بر اساس **آدرس** ممکن بود. اکنون: +## ۵. جست‌وجو | فیلد | جست‌وجو در | |---|---| @@ -430,8 +563,7 @@ python manage.py test tests | آدرس | `address` | | کد مشتری | `idCode` | -نما و مرتب‌سازی هنگام جست‌وجو **حفظ می‌شوند** و دکمهٔ «حذف جستجو» برای برگشت -به فهرست کامل هست. +نما، فیلتر تأخیر و مرتب‌سازی هنگام جست‌وجو **حفظ می‌شوند**. ## ۵. چیدمان ستون‌ها (بازطراحی) @@ -506,23 +638,37 @@ python manage.py test tests ## ۱۰. پارامترهای URL ``` -/panel/entezar/ نمای پیش‌فرض (بدون قرار، قدیمی‌ترین) -?view=all همهٔ معوق‌ها -?view=coordinated با قرار (اجتماع سه منبع) -?view=manual فقط قرار دستی +/panel/entezar/ پیش‌فرض: نیازمند پیگیری امروز +?view=all همهٔ عقب‌افتاده‌ها +?view=coordinated تعیین تکلیف شده (اجتماع سه منبع) +?view=today نیازمند پیگیری امروز +?view=today&fdate=1405/07/28 نیازمند پیگیری در یک روز دیگر (شمسی) +?view=manual فقط تعویق دستی ?view=scheduled فقط بازدید برنامه‌ریزی‌شده -?view=coordination فقط هماهنگی -?sort=overdue بیشترین تأخیر اول -?sort=newest جدیدترین بازدید اول +?view=coordination فقط پیگیری تلفنی +?delay=gt180 فقط بیش از ۶ ماه تأخیر +?sort=overdue&dir=desc بیشترین تأخیر اول +?sort=name&dir=asc الفبایی (صعودی) +?sort=manual&dir=desc تاریخ قرار دستی (نزولی) ?searchVal=رضایی&searchOn=name جست‌وجوی نام ?searchVal=0912&searchOn=phone جست‌وجوی تلفن -?view=all&sort=overdue&page=3 ترکیب‌پذیر +?view=all&delay=gt180&sort=name&dir=asc&page=3 همه با هم (ترکیب‌پذیر) +?view=today&missed=1 فقط پیگیری‌های عقب‌افتاده ``` -مقدار نامعتبر `view` یا `sort` بی‌صدا به پیش‌فرض برمی‌گردد (هیچ خطای ۴۰۰/۵۰۰). +- **اندازهٔ صفحه = ۱۰۰ ردیف** (`ENTEZAR_PAGE_SIZE`)؛ صفحه‌بندی فقط از ردیف ۱۰۱ به + بعد فعال می‌شود. -3. پس از تاریخ قرار، مشتری **خودکار** در گروه A ظاهر می‌شود؛ نیازی به کار دستی نیست. -4. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است. +- مقدار نامعتبر `view`/`sort`/`delay` بی‌صدا به پیش‌فرض برمی‌گردد (بدون ۴۰۰/۵۰۰). +- مقادیر قدیمی `sort=oldest|newest|overdue` همچنان معتبرند. +- همهٔ لینک‌های داخلی (کارت‌ها، بازه‌های تأخیر، سرستون‌ها، صفحه‌بندی) **بقیهٔ + فیلترها را حفظ می‌کنند**. + +--- + +3. پس از تاریخ قرار، مشتری **خودکار** در گروه «تعیین تکلیف نشده» ظاهر می‌شود؛ + نیازی به کار دستی نیست. +4. عدد «تعیین تکلیف نشده» در داشبورد همان لیست کاریِ فوری است. diff --git a/khatibi/settings.py b/khatibi/settings.py index 6f2b10b..227a7e2 100644 --- a/khatibi/settings.py +++ b/khatibi/settings.py @@ -78,7 +78,7 @@ WSGI_APPLICATION = 'khatibi.wsgi.application' DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'data9/kt.sqlite3', + 'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261020_223011.sqlite3', } } diff --git a/tests/test_view_smoke.py b/tests/test_view_smoke.py index 88f3c47..a9dda70 100644 --- a/tests/test_view_smoke.py +++ b/tests/test_view_smoke.py @@ -9,6 +9,8 @@ هیچ پاسخی نباید کد ۵۰۰ داشته باشد. """ import json +import re +from html.parser import HTMLParser from django.test import TestCase from django.urls import reverse @@ -257,3 +259,114 @@ class ViewSmokeTests(TestCase): f"داده‌اند: {failures}" ), ) + + +class _DivBalanceParser(HTMLParser): + """شمارندهٔ عمق
با احترام به محتوای