From f8d768a724dc4a5ed538a4ae8cb3339cd1d69fef Mon Sep 17 00:00:00 2001 From: javad Date: Wed, 21 Oct 2026 11:28:01 +0330 Subject: [PATCH] improve bazdid 2 --- .../templates/admin_panel/entezar.html | 124 +++++++++++---- admin_panel/views/actions.py | 36 ++--- admin_panel/views/customers.py | 12 +- admin_panel/visit_policy.py | 116 ++++++++++---- docs/visit_hold_entezar.md | 35 ++++- tests/test_visit_policy.py | 143 +++++++++++++++++- 6 files changed, 374 insertions(+), 92 deletions(-) diff --git a/admin_panel/templates/admin_panel/entezar.html b/admin_panel/templates/admin_panel/entezar.html index 2e4bc6b..14897ca 100644 --- a/admin_panel/templates/admin_panel/entezar.html +++ b/admin_panel/templates/admin_panel/entezar.html @@ -11,29 +11,55 @@ @media (min-width: 769px) { .ez-wrap { padding: 1rem 1.25rem; } } /* ---------- نوار آمار ---------- */ + .ez-stats-head { margin: 0 0 .55rem; } + .ez-stats-title { font-size: .92rem; font-weight: 800; color: #0f172a; margin: 0; } + .ez-stats-hint { + font-size: .72rem; color: #64748b; margin: .2rem 0 0; line-height: 1.7; + } + .ez-stats-hint b { color: #334155; } + .ez-stats { - display: grid; grid-auto-flow: column; grid-auto-columns: minmax(8.5rem, 1fr); - gap: .5rem; overflow-x: auto; padding-bottom: .25rem; + display: grid; grid-auto-flow: column; grid-auto-columns: minmax(11rem, 1fr); + gap: .5rem; overflow-x: auto; padding-bottom: .35rem; -webkit-overflow-scrolling: touch; } @media (min-width: 1024px) { .ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; } } .ez-stat { - display: block; border: 1px solid #e2e8f0; border-radius: .625rem; - background: #fff; padding: .55rem .7rem; text-decoration: none; color: #334155; - transition: border-color .15s ease, background-color .15s ease; + display: flex; flex-direction: column; gap: .18rem; + border: 1px solid #e2e8f0; border-radius: .625rem; background: #fff; + padding: .55rem .65rem .6rem; text-decoration: none; color: #334155; + border-top: 3px solid #cbd5e1; + transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease; + } + .ez-stat:hover { border-color: #94a3b8; box-shadow: 0 1px 6px rgba(15, 23, 42, .08); } + /* نوار رنگی بالای هر کارت = همان رنگ برچسب وضعیت در جدول */ + .ez-stat.total { border-top-color: #64748b; } + .ez-stat.danger { border-top-color: #dc2626; } + .ez-stat.success { border-top-color: #16a34a; } + .ez-stat.warning { border-top-color: #d97706; } + .ez-stat.link { border-top-color: #0284c7; } + + .ez-stat-top { + display: flex; align-items: baseline; justify-content: space-between; gap: .4rem; + } + .ez-stat .ez-lbl { + font-size: .74rem; font-weight: 700; color: #334155; line-height: 1.35; } - .ez-stat:hover { border-color: #94a3b8; } .ez-stat .ez-num { - display: block; font-size: 1.2rem; font-weight: 800; line-height: 1.1; - font-variant-numeric: tabular-nums; + font-size: 1.25rem; font-weight: 800; line-height: 1.1; + font-variant-numeric: tabular-nums; flex: 0 0 auto; + } + .ez-stat .ez-desc { + font-size: .68rem; color: #7c8798; line-height: 1.6; } - .ez-stat .ez-lbl { display: block; margin-top: .15rem; font-size: .7rem; color: #64748b; } .ez-stat.is-active { - border-color: #33b5d3; background: #f1fbff; box-shadow: inset 0 -3px 0 #33b5d3; + border-color: #33b5d3; background: #f1fbff; + box-shadow: inset 0 -3px 0 #33b5d3, 0 1px 6px rgba(51, 181, 211, .18); } - .ez-stat.is-active .ez-lbl { color: #0e7490; font-weight: 700; } + .ez-stat.is-active .ez-lbl { color: #0e7490; } + .ez-stat.is-active .ez-desc { color: #0e7490; } /* ---------- تفکیک تأخیر ---------- */ .ez-legend { @@ -169,40 +195,78 @@
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #} +
+

وضعیت مشتریانِ عقب‌افتاده

+

+ «عقب‌افتاده» یعنی از آخرین بازدید + بیش از {{ counts.period_months }} ماه گذشته باشد. + روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛ + کل = بدون قرار + با قرار. + {% if counts.both %} + ({{ counts.both }} مشتری هم قرار هماهنگی دارند و هم بازدید + برنامه‌ریزی‌شده، پس در هر دو کارت شمرده می‌شوند.) + {% endif %} +

+
+
- {{ counts.total }} - کل معوق + class="ez-stat total {% if view == 'all' %}is-active{% endif %}" + title="همهٔ مشتریان عقب‌افتاده، با هر وضعیت قرار"> + + کل عقب‌افتاده‌ها + {{ counts.total }} + + همه، فارغ از داشتن یا نداشتن قرار + - {{ counts.uncoordinated }} - بدون قرار + class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}" + title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است"> + + بدون قرار + {{ counts.uncoordinated }} + + هنوز وقت بازدید تعیین نشده — نیاز به تماس + - {{ counts.coordinated }} - با قرار + class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}" + title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است"> + + با قرار + {{ counts.coordinated }} + + وقت بازدیدشان مشخص است (هر دو نوع قرار) + - {{ counts.manual }} - قرار هماهنگی + class="ez-stat warning {% if view == 'manual' %}is-active{% endif %}" + title="قرار تعویق که از فرم «هماهنگی» ثبت شده است"> + + قرار هماهنگی + {{ counts.manual }} + + تعویق دستی از فرم هماهنگی (مثلاً «ماه بعد زنگ بزنید») + - {{ counts.scheduled }} - بازدید برنامه‌ریزی‌شده + class="ez-stat link {% if view == 'scheduled' %}is-active{% endif %}" + title="اقدام «بازدید» با تاریخ آینده"> + + بازدید برنامه‌ریزی‌شده + {{ counts.scheduled }} + + اقدام «بازدید» با تاریخ آینده ثبت شده است
{# تفکیک مدت تأخیر در نمای جاری #}
- تأخیر: - {{ counts.buckets.lt30 }} زیر ۳۰ روز - {{ counts.buckets.30to90 }} ۳۰–۹۰ روز - {{ counts.buckets.90to180 }} ۹۰–۱۸۰ روز + میزان تأخیر در همین نما: + {{ counts.buckets.lt30 }} تا ۳۰ روز + {{ counts.buckets.30to90 }} ۳۰ تا ۹۰ روز + {{ counts.buckets.90to180 }} ۹۰ تا ۱۸۰ روز {{ counts.buckets.gt180 }} بیش از ۶ ماه
diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py index f9bd992..00e1bfe 100644 --- a/admin_panel/views/actions.py +++ b/admin_panel/views/actions.py @@ -91,24 +91,29 @@ def _action_payload_from_request(request): } -def _apply_visit_hold(customer_obj, payload): - """نوشتن/پاک‌کردن «قرار بعدی» بر اساس اقدام ثبت/ویرایش‌شده. +def _apply_visit_hold(customer_obj, payload, *, status): + """هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده. - قواعد: - * فقط برای اقدام نوع «هماهنگی» معنا دارد. - * اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو). - * اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیش‌فرض ۱۴ روز. - * اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک می‌شود. + قواعد (یکسان برای ایجاد و ویرایش — قبلاً ایجاد این را انجام نمی‌داد): + + * فقط برای اقدام نوع «هماهنگی» معنا دارد؛ + - اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو) + - اگر «انجام شد» تیک خورده و تاریخ خالی است ⇒ پیش‌فرض ۱۴ روز + - اگر هماهنگی لغو شده باشد ⇒ قرار پاک می‌شود + * **هر اقدام دیگری** (از جمله «بازدید») ⇒ قرار دستی پاک می‌شود؛ چون هدفِ + قرار (پیگیری در آینده) با انجام/برنامه‌ریزی بازدید منتفی است. """ - if payload['actionType'] != Action.ActionType.HAMAHANGI: - return + is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI + if not is_coordination or status == Action.Status.CANCELL: + return clear_visit_hold(customer_obj) + raw = payload.get('holdRaw') if raw is not None: - target = normalize_hold_until(raw) - set_visit_hold(customer_obj, target) - return + set_visit_hold(customer_obj, normalize_hold_until(raw)) + return True if payload['status'] == Action.Status.FINISH: set_visit_hold(customer_obj, default_hold_until()) + return True def _reject(request, error, redirect_to): @@ -147,7 +152,7 @@ def customerNewAction(request ,c_id): # last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime. refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user) # «قرار بعدی» فقط برای هماهنگی. - _apply_visit_hold(customer_obj, payload) + _apply_visit_hold(customer_obj, payload, status=newAction.status) # پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید. if newAction.actionType == Action.ActionType.BAZDID: @@ -207,10 +212,7 @@ def customerEditAction(request ,c_id, action_id): refresh_last_bazdid(customer_obj, reason=reason, user=request.user) # «قرار بعدی»: با ویرایش هم‌راستا می‌شود؛ اگر اقدام دیگر هماهنگی نباشد یا # هماهنگی لغو شود، قرار دستی پاک می‌شود. - if payload['actionType'] == Action.ActionType.HAMAHANGI and newAction.status != Action.Status.CANCELL: - _apply_visit_hold(customer_obj, payload) - else: - clear_visit_hold(customer_obj) + _apply_visit_hold(customer_obj, payload, status=newAction.status) #return redirect('/panel/customer/'+str(c_id)+'/action') return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py index 60792ac..3731f1b 100644 --- a/admin_panel/views/customers.py +++ b/admin_panel/views/customers.py @@ -492,12 +492,20 @@ def entezar(request): counts = { 'total': len(stale_ids), 'uncoordinated': len(groups['A']), - 'manual': len(groups['B']), - 'scheduled': len(groups['C']), + # توجه: عددِ هر کارت باید **همان چیزی** باشد که با کلیک روی آن کارت + # دیده می‌شود. فیلتر ``view=manual`` گروه‌های B و D را نشان می‌دهد و + # ``view=scheduled`` گروه‌های C و D را؛ پس اینجا هم همان اجتماع + # شمرده می‌شود (قبلاً فقط B/C شمرده می‌شد و عدد با فهرست نمی‌خواند). + 'manual': len(groups['B']) + len(groups['D']), + 'scheduled': len(groups['C']) + len(groups['D']), + 'manual_only': len(groups['B']), + 'scheduled_only': len(groups['C']), 'both': len(groups['D']), 'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']), 'selected': len(selected), 'buckets': buckets, + # برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»). + 'period_months': period_months, } try: diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py index 916ae01..e804b95 100644 --- a/admin_panel/visit_policy.py +++ b/admin_panel/visit_policy.py @@ -338,24 +338,35 @@ SOURCE_SCHEDULED = "scheduled" SOURCE_COORDINATION = "coordination" -def _future_actions(action_type, statuses, *, now_utc): - """اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).""" +def _future_actions(action_type, statuses, *, now_utc, whole_day=False): + """اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام). + + ``whole_day=True`` فقط برای «بازدید برنامه‌ریزی‌شده» استفاده می‌شود: در آنجا + ``actionDateTime`` یک **قرارِ آینده** است که کاربر با تقویم روزانه انتخاب + می‌کند، پس «قرار امروز» باید تا پایان امروز معتبر بماند. مقایسهٔ لحظه‌ای + باعث می‌شد بازدیدِ امروز بلافاصله پس از گذشتن ساعتش ناپدید شود. + + برای «هماهنگی» عمداً ``whole_day=False`` است: ``actionDateTime`` آنجا + **زمانِ تماس** است، نه قرار آینده؛ تماسِ گذشته نباید مشتری را تا آخر امروز + پنهان کند (وگرنه گروه «قرار دستی» در سناریوی رایج خالی می‌شد). + """ + threshold = start_of_local_day(now_utc) if whole_day else now_utc return ( Action.objects.filter(actionType=action_type, status__in=statuses) .filter( - models.Q(actionDateTime__gt=now_utc) - | models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc) + models.Q(actionDateTime__gte=threshold) + | models.Q(actionDateTime__isnull=True, finishDateTime__gte=threshold) ) .values_list("customer_id", "actionDateTime", "finishDateTime") ) -def _action_appointments(action_type, statuses, *, now=None): +def _action_appointments(action_type, statuses, *, now=None, whole_day=False): """نگاشت ``customer_id → زودترین تاریخِ آینده`` برای یک نوع اقدام.""" now_utc = _now(now).astimezone(dt_timezone.utc) result = {} for customer_id, action_dt, finish_dt in _future_actions( - action_type, statuses, now_utc=now_utc + action_type, statuses, now_utc=now_utc, whole_day=whole_day ).iterator(): when = as_aware(action_dt) or as_aware(finish_dt) if when is None: @@ -367,9 +378,13 @@ def _action_appointments(action_type, statuses, *, now=None): def scheduled_visits_map(*, now=None): - """قرارهای «بازدید برنامه‌ریزی‌شده»: ``bazdid + waite`` با تاریخ آینده.""" + """قرارهای «بازدید برنامه‌ریزی‌شده»: ``bazdid + waite`` با تاریخ آینده. + + مقایسه در سطح **روز** است (``whole_day=True``): بازدیدی که برای «امروز» + گذاشته شده تا پایان امروز معتبر می‌ماند. + """ return _action_appointments( - Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now, + Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now, whole_day=True, ) @@ -390,17 +405,19 @@ def coordination_holds_map(*, now=None): def manual_holds_map(*, now=None): """قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**. - قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام - «هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان - مشتری وجود داشته باشد. بنابراین: + قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که: - * حذف هماهنگی ⇒ قرار خودکار بی‌اثر می‌شود، - * لغو هماهنگی (``cancell``) ⇒ بی‌اثر می‌شود، - * هماهنگیِ گذشته ⇒ بی‌اثر می‌شود، - * کهنه‌ماندن ستون هیچ اثر مخربی ندارد. + ۱. تاریخش از حالا جلوتر باشد، + ۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد، + ۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام + دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است. - مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)، - چون کاربر می‌تواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد. + بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛ + بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار» + می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است. + + نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛ + و کهنه‌ماندن ستون هیچ اثر مخربی ندارد. """ now = _now(now) rows = ( @@ -412,23 +429,44 @@ def manual_holds_map(*, now=None): if not holds: return {} + customer_ids = list(holds) + # مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با - # تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار - # دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده بودن تاریخ - # اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بی‌اثر می‌شد. - # پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد). - # ``cancell`` حساب نمی‌شود و حذف رکورد هم خودکار بی‌اثر می‌کند. - active_coordination = set( + # تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد + # گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده‌بودن تاریخ اقدام را + # بگذاریم، همین مورد (هدف اصلی این قابلیت) بی‌اثر می‌شد. پس فقط وضعیت مهم است. + coordination_latest = dict( Action.objects.filter( + customer_id__in=customer_ids, actionType=Action.ActionType.HAMAHANGI, status__in=(Action.Status.WAITE, Action.Status.FINISH), - ).values_list("customer_id", flat=True) + ) + .values("customer_id") + .annotate(last=Max("createdDT")) + .values_list("customer_id", "last") ) - return { - customer_id: until - for customer_id, until in holds.items() - if customer_id in active_coordination and until is not None - } + # آخرین فعالیتِ غیرهماهنگی (بازدید، نصب، تماس، …) برای هر مشتری. + other_latest = dict( + Action.objects.filter(customer_id__in=customer_ids) + .exclude(actionType=Action.ActionType.HAMAHANGI) + .values("customer_id") + .annotate(last=Max("createdDT")) + .values_list("customer_id", "last") + ) + + valid = {} + for customer_id, until in holds.items(): + if until is None: + continue + coordinated_at = coordination_latest.get(customer_id) + if coordinated_at is None: + continue + activity_at = other_latest.get(customer_id) + if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at): + # پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است. + continue + valid[customer_id] = until + return valid def appointment_sources_map(*, now=None): @@ -467,6 +505,26 @@ HOLD_DEFAULT_DAYS = 14 HOLD_MAX_DAYS = 183 # ≈ ۶ ماه +def start_of_local_day(moment): + """آغاز همان روز به وقت محلی (به‌صورت aware). + + قرارها با تقویم روزانه انتخاب می‌شوند؛ پس «قرار امروز» باید تا پایان امروز + معتبر بماند. مقایسهٔ لحظه‌ای باعث می‌شد قرارِ امروز بلافاصله پس از گذشتن + ساعتِ آن، ناپدید شود. + """ + moment = as_aware(moment) + if moment is None: + return None + local = moment.astimezone(timezone.get_current_timezone()) + start_of_day = datetime.combine(local.date(), time.min) + try: + return timezone.make_aware(start_of_day, timezone.get_current_timezone()) + except Exception: + return timezone.make_aware( + start_of_day, timezone.get_current_timezone(), is_dst=False, + ) + + def end_of_local_day(moment): """پایان همان روز به وقت محلی (به‌صورت aware). diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md index 96d6713..4f2cbf1 100644 --- a/docs/visit_hold_entezar.md +++ b/docs/visit_hold_entezar.md @@ -38,8 +38,17 @@ ### قاعدهٔ طلایی: ستون، «مرجع» نیست -> `visit_hold_until` هیچ اعتباری به‌تنهایی ندارد. فقط وقتی اثر دارد که برای همان -> مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد. +> `visit_hold_until` هیچ اعتباری به‌تنهایی ندارد. یک قرار دستی فقط وقتی اثر دارد +> که **هر سه** شرط برقرار باشد: +> +> ۱. تاریخش از حالا جلوتر باشد، +> ۲. برای همان مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد، +> ۳. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدام دیگری +> (به‌ویژه «بازدید») ثبت شده باشد، قرار منتفی است. + +شرط ۳ برای این است که «ثبت بازدید پس از هماهنگی» قرار را خودکار باطل کند. +بدون آن، قرار قدیمی تا تاریخش زنده می‌ماند و مشتری در فهرست «با قرار» می‌ماند، +هرچند بازدید جای هماهنگی را گرفته است. نتیجهٔ این قاعده: @@ -48,6 +57,7 @@ | حذف اقدام هماهنگی | قرار **خودکار بی‌اثر** می‌شود | | لغو هماهنگی (`cancell`) | بی‌اثر می‌شود | | ویرایش تاریخ هماهنگی | قرار با تاریخ جدید هم‌راستا می‌شود | +| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بی‌اثر** می‌شود | | کهنه ماندن ستون | **هیچ اثر مخربی ندارد** | به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است. @@ -58,15 +68,26 @@ تصمیم «مشتری در فهرست هست یا نه» از اجتماع سه منبع گرفته می‌شود: -| منبع | تعریف | تاریخ قرار | -|---|---|---| -| `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) | -| `scheduled` | `bazdid + waite` با تاریخ آینده | `actionDateTime` | -| `coordination` | `hamahangi` با `waite` یا `finish` و تاریخ آینده | `actionDateTime` (یا `finishDateTime`) | +| منبع | تعریف | تاریخ قرار | مرز زمانی | +|---|---|---|---| +| `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) | پایان امروز | +| `scheduled` | `bazdid + waite` | `actionDateTime` | **پایان روز** | +| `coordination` | `hamahangi` با `waite` یا `finish` | `actionDateTime` (یا `finishDateTime`) | **لحظه‌ای** | تابع واحد: `visit_policy.next_appointment_map(now)` — **همان تابعی که اسکنر پیامک هم استفاده می‌کند**، پس صفحه و پیامک هرگز واگرا نمی‌شوند. +**چرا مرز زمانیِ دو منبع فرق دارد؟** + +* در `scheduled`، `actionDateTime` یک **قرارِ آینده** است که کارشناس با تقویم + روزانه انتخاب می‌کند؛ پس «قرار امروز» باید تا پایان امروز معتبر بماند. با + مقایسهٔ لحظه‌ای، بازدیدِ امروز بلافاصله پس از گذشتن ساعتش از فهرست ناپدید + می‌شد (باگی که گزارش شد). +* در `coordination`، `actionDateTime` **زمانِ تماس** است، نه قرار آینده. اگر آن + را هم تا پایان روز معتبر بگیریم، هر تماسِ امروز مشتری را تا آخر امروز از + گروه «قرار دستی» بیرون می‌برد و سناریوی اصلی (تماس امروز + قرار دو هفته بعد) + بی‌اثر می‌شد. + اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای «بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود. diff --git a/tests/test_visit_policy.py b/tests/test_visit_policy.py index 5a9ff96..fa9f834 100644 --- a/tests/test_visit_policy.py +++ b/tests/test_visit_policy.py @@ -927,6 +927,80 @@ class VisitHoldTests(TestCase): ) self.assertNotIn(customer.pk, self._ids()) + # ---------- ثبت بازدید پس از هماهنگی ⇒ قرار هماهنگی منتفی می‌شود ---------- + def test_visit_created_after_coordination_clears_manual_hold(self): + """سناریوی گزارش‌شده: هماهنگی + قرار، سپس ثبت «بازدید». + + انتظار: مشتری از `view=manual` خارج شود و در `view=scheduled` دیده شود. + """ + customer = self._customer('بازدید بعد از هماهنگی') + Action.objects.create( + customer=customer, actionType='hamahangi', status='finish', + # «چند دقیقه پیش تماس گرفتم» — از مرز لحظه‌ای فاصله دارد. + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5), + ) + set_visit_hold(customer, timezone.now() + timedelta(days=2)) + self.assertIn(customer.pk, self._ids('manual')) + + # بازدید برنامه‌ریزی‌شده (بدون تیک «انجام شد»). + self._post_action( + customer, actionType='bazdid', + dateTimeAnjam=self._ms(timezone.now() + timedelta(hours=2)), + desc='بازدید برنامه‌ریزی شد', + ) + + customer.refresh_from_db() + self.assertIsNone(customer.visit_hold_until, 'قرار دستی باید پاک شود') + self.assertNotIn(customer.pk, self._ids('manual')) + self.assertIn(customer.pk, self._ids('scheduled')) + + def test_visit_created_after_coordination_without_hold_still_schedules(self): + customer = self._customer('بدون قرار قبلی') + self._post_action( + customer, actionType='bazdid', + dateTimeAnjam=self._ms(timezone.now() + timedelta(days=3)), + desc='بازدید', + ) + self.assertIn(customer.pk, self._ids('scheduled')) + + def test_old_visit_does_not_void_newer_coordination(self): + """بازدید قدیمی‌تر نباید قرار هماهنگیِ جدیدتر را باطل کند.""" + customer = self._customer('هماهنگی جدیدتر') + Action.objects.create( + customer=customer, actionType='bazdid', status='finish', + actionDateTime=timezone.now() - timedelta(days=400), + finishDateTime=timezone.now() - timedelta(days=400), + ) + Action.objects.create( + customer=customer, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5), + ) + set_visit_hold(customer, timezone.now() + timedelta(days=20)) + self.assertIn(customer.pk, self._ids('manual')) + + def test_scheduled_visit_earlier_today_still_counts(self): + """بازدیدِ برنامه‌ریزی‌شده برای «امروز» تا پایان امروز معتبر است. + + رگرسیون: فیلتر قبلی لحظه‌ای بود و بازدیدی که ساعتش چند دقیقه پیش گذشته + بود از فهرست ناپدید می‌شد. + """ + customer = self._customer('بازدید امروز') + Action.objects.create( + customer=customer, actionType='bazdid', status='waite', + actionDateTime=timezone.now() - timedelta(minutes=30), + ) + self.assertIn(customer.pk, self._ids('scheduled')) + + def test_scheduled_visit_yesterday_is_not_upcoming(self): + customer = self._customer('بازدید دیروز') + Action.objects.create( + customer=customer, actionType='bazdid', status='waite', + actionDateTime=timezone.now() - timedelta(days=1), + ) + self.assertNotIn(customer.pk, self._ids('scheduled')) + # ---------- تفکیک چهارگروهی و فیلترها ---------- def test_groups_and_filters(self): @@ -937,23 +1011,31 @@ class VisitHoldTests(TestCase): # b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد). Action.objects.create(customer=b, actionType='hamahangi', status='finish', - actionDateTime=timezone.now(), finishDateTime=timezone.now()) + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5)) set_visit_hold(b, timezone.now() + timedelta(days=20)) Action.objects.create(customer=c, actionType='bazdid', status='waite', actionDateTime=timezone.now() + timedelta(days=7)) - # d: هم قرار دستی و هم بازدید برنامه‌ریزی‌شده. - Action.objects.create(customer=d, actionType='hamahangi', status='finish', - actionDateTime=timezone.now(), finishDateTime=timezone.now()) - set_visit_hold(d, timezone.now() + timedelta(days=25)) + # d: بازدید برنامه‌ریزی‌شده (قدیمی‌تر) + قرار دستی (جدیدتر). + # ترتیب مهم است: اگر بازدید *پس از* هماهنگی ثبت شود، هماهنگی منتفی + # محسوب می‌شود (تست جداگانهٔ زیر). اینجا برعکس است تا گروه D پوشش داده شود. Action.objects.create(customer=d, actionType='bazdid', status='waite', actionDateTime=timezone.now() + timedelta(days=9)) + Action.objects.create(customer=d, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5)) + set_visit_hold(d, timezone.now() + timedelta(days=25)) counts = self._entezar().context['counts'] self.assertEqual(counts['uncoordinated'], 1) # a - self.assertEqual(counts['manual'], 1) # b - self.assertEqual(counts['scheduled'], 1) # c + # عددِ کارت باید با فهرستی که با کلیک باز می‌شود بخواند: + # view=manual گروه‌های B و D را نشان می‌دهد. + self.assertEqual(counts['manual'], 2) # b + d + self.assertEqual(counts['scheduled'], 2) # c + d + self.assertEqual(counts['manual_only'], 1) # b + self.assertEqual(counts['scheduled_only'], 1) # c self.assertEqual(counts['both'], 1) # d self.assertEqual(counts['total'], 4) self.assertEqual(counts['coordinated'], 3) @@ -969,6 +1051,53 @@ class VisitHoldTests(TestCase): self.assertEqual(self._ids('nonsense'), {a.pk}) self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated') + def test_card_number_matches_filtered_list(self): + """عدد هر کارت باید با تعداد ردیف‌های همان فیلتر یکی باشد. + + رگرسیون: کارت «قرار هماهنگی» فقط گروه B را می‌شمرد، ولی فیلتر + ``view=manual`` گروه D را هم نشان می‌داد؛ کاربر عدد ۰ می‌دید و با کلیک + ردیف پیدا می‌کرد. + """ + b = self._customer('قرار دستی تنها') + Action.objects.create(customer=b, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5)) + set_visit_hold(b, timezone.now() + timedelta(days=20)) + + d = self._customer('هم قرار و هم بازدید') + Action.objects.create(customer=d, actionType='bazdid', status='waite', + actionDateTime=timezone.now() + timedelta(days=9)) + Action.objects.create(customer=d, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5)) + set_visit_hold(d, timezone.now() + timedelta(days=25)) + + counts = self._entezar().context['counts'] + self.assertEqual(counts['manual'], len(self._ids('manual'))) + self.assertEqual(counts['scheduled'], len(self._ids('scheduled'))) + self.assertEqual(counts['coordinated'], len(self._ids('coordinated'))) + self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated'))) + self.assertEqual(counts['total'], len(self._ids('all'))) + + # وقتی مشتری‌ای هر دو نوع قرار را دارد، راهنمای بالای صفحه باید + # توضیح بدهد که چرا جمع دو کارت با «با قرار» برابر نیست. + self.assertGreaterEqual(counts['both'], 1) + self.assertIn('پس در هر دو کارت شمرده می‌شوند', self._entezar().content.decode()) + + def test_stats_cards_have_minimal_descriptions(self): + """هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد.""" + html = self._entezar().content.decode() + # سرِ نوار آمار + توضیح «عقب‌افتاده» + self.assertIn('ez-stats-title', html) + self.assertIn('ez-stats-hint', html) + # پنج توضیح، یکی برای هر کارت + self.assertEqual(html.count('class="ez-desc"'), 5) + for phrase in ('هنوز وقت بازدید تعیین نشده', + 'وقت بازدیدشان مشخص است', + 'تعویق دستی از فرم هماهنگی', + 'اقدام «بازدید» با تاریخ آینده'): + self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست') + # ---------- اسکنر پیامک هم قرار دستی را می‌بیند ---------- def test_scanner_skips_customer_with_manual_hold(self): from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue