improve bazdid 2

This commit is contained in:
javad committed 2026-10-21 11:28:01 +03:30
1 parent 534a84f977
commit f8d768a724
6 files changed
+374 -92

No files matched your search

+94 -30
View File
@@ -11,29 +11,55 @@
@media (min-width: 769px) { .ez-wrap { padding: 1rem 1.25rem; } } @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 { .ez-stats {
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(8.5rem, 1fr); display: grid; grid-auto-flow: column; grid-auto-columns: minmax(11rem, 1fr);
gap: .5rem; overflow-x: auto; padding-bottom: .25rem; gap: .5rem; overflow-x: auto; padding-bottom: .35rem;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; } .ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; }
} }
.ez-stat { .ez-stat {
display: block; border: 1px solid #e2e8f0; border-radius: .625rem; display: flex; flex-direction: column; gap: .18rem;
background: #fff; padding: .55rem .7rem; text-decoration: none; color: #334155; border: 1px solid #e2e8f0; border-radius: .625rem; background: #fff;
transition: border-color .15s ease, background-color .15s ease; 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 { .ez-stat .ez-num {
display: block; font-size: 1.2rem; font-weight: 800; line-height: 1.1; font-size: 1.25rem; font-weight: 800; line-height: 1.1;
font-variant-numeric: tabular-nums; 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 { .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 { .ez-legend {
@@ -169,40 +195,78 @@
<div class="columns m-0"> <div class="columns m-0">
<div class="column is-12 p-0"> <div class="column is-12 p-0">
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #} {# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
<div class="ez-stats-head">
<p class="ez-stats-title">وضعیت مشتریانِ عقب‌افتاده</p>
<p class="ez-stats-hint">
«عقب‌افتاده» یعنی از آخرین بازدید
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = بدون قرار + با قرار</b>.
{% if counts.both %}
({{ counts.both }} مشتری هم قرار هماهنگی دارند و هم بازدید
برنامه‌ریزی‌شده، پس در هر دو کارت شمرده می‌شوند.)
{% endif %}
</p>
</div>
<div class="ez-stats"> <div class="ez-stats">
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}" <a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat {% if view == 'all' %}is-active{% endif %}"> class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
<span class="ez-num">{{ counts.total }}</span> title="همهٔ مشتریان عقب‌افتاده، با هر وضعیت قرار">
<span class="ez-lbl">کل معوق</span> <span class="ez-stat-top">
<span class="ez-lbl">کل عقب‌افتاده‌ها</span>
<span class="ez-num">{{ counts.total }}</span>
</span>
<span class="ez-desc">همه، فارغ از داشتن یا نداشتن قرار</span>
</a> </a>
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}" <a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat {% if view == 'uncoordinated' %}is-active{% endif %}"> class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span> title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است">
<span class="ez-lbl">بدون قرار</span> <span class="ez-stat-top">
<span class="ez-lbl">بدون قرار</span>
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
</span>
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
</a> </a>
<a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}" <a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat {% if view == 'coordinated' %}is-active{% endif %}"> class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
<span class="ez-num has-text-success">{{ counts.coordinated }}</span> title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است">
<span class="ez-lbl">با قرار</span> <span class="ez-stat-top">
<span class="ez-lbl">با قرار</span>
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
</span>
<span class="ez-desc">وقت بازدیدشان مشخص است (هر دو نوع قرار)</span>
</a> </a>
<a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}" <a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat {% if view == 'manual' %}is-active{% endif %}"> class="ez-stat warning {% if view == 'manual' %}is-active{% endif %}"
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span> title="قرار تعویق که از فرم «هماهنگی» ثبت شده است">
<span class="ez-lbl">قرار هماهنگی</span> <span class="ez-stat-top">
<span class="ez-lbl">قرار هماهنگی</span>
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
</span>
<span class="ez-desc">تعویق دستی از فرم هماهنگی (مثلاً «ماه بعد زنگ بزنید»)</span>
</a> </a>
<a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}" <a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat {% if view == 'scheduled' %}is-active{% endif %}"> class="ez-stat link {% if view == 'scheduled' %}is-active{% endif %}"
<span class="ez-num has-text-link">{{ counts.scheduled }}</span> title="اقدام «بازدید» با تاریخ آینده">
<span class="ez-lbl">بازدید برنامه‌ریزی‌شده</span> <span class="ez-stat-top">
<span class="ez-lbl">بازدید برنامه‌ریزی‌شده</span>
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
</span>
<span class="ez-desc">اقدام «بازدید» با تاریخ آینده ثبت شده است</span>
</a> </a>
</div> </div>
{# تفکیک مدت تأخیر در نمای جاری #} {# تفکیک مدت تأخیر در نمای جاری #}
<div class="ez-legend"> <div class="ez-legend">
<span>تأخیر:</span> <span>میزان تأخیر در همین نما:</span>
<span><b>{{ counts.buckets.lt30 }}</b> زیر ۳۰ روز</span> <span><b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز</span>
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰–۹۰ روز</span> <span><b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز</span>
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰–۱۸۰ روز</span> <span><b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز</span>
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span> <span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
</div> </div>
+19 -17
View File
@@ -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`` (فقط رو به جلو). * فقط برای اقدام نوع «هماهنگی» معنا دارد؛
* اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیش‌فرض ۱۴ روز. - اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو)
* اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک می‌شود. - اگر «انجام شد» تیک خورده و تاریخ خالی است ⇒ پیش‌فرض ۱۴ روز
- اگر هماهنگی لغو شده باشد ⇒ قرار پاک می‌شود
* **هر اقدام دیگری** (از جمله «بازدید») ⇒ قرار دستی پاک می‌شود؛ چون هدفِ
قرار (پیگیری در آینده) با انجام/برنامه‌ریزی بازدید منتفی است.
""" """
if payload['actionType'] != Action.ActionType.HAMAHANGI: is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
return if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj)
raw = payload.get('holdRaw') raw = payload.get('holdRaw')
if raw is not None: if raw is not None:
target = normalize_hold_until(raw) set_visit_hold(customer_obj, normalize_hold_until(raw))
set_visit_hold(customer_obj, target) return True
return
if payload['status'] == Action.Status.FINISH: if payload['status'] == Action.Status.FINISH:
set_visit_hold(customer_obj, default_hold_until()) set_visit_hold(customer_obj, default_hold_until())
return True
def _reject(request, error, redirect_to): def _reject(request, error, redirect_to):
@@ -147,7 +152,7 @@ def customerNewAction(request ,c_id):
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime. # last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user) 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: 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) 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, status=newAction.status)
_apply_visit_hold(customer_obj, payload)
else:
clear_visit_hold(customer_obj)
#return redirect('/panel/customer/'+str(c_id)+'/action') #return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
+10 -2
View File
@@ -492,12 +492,20 @@ def entezar(request):
counts = { counts = {
'total': len(stale_ids), 'total': len(stale_ids),
'uncoordinated': len(groups['A']), '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']), 'both': len(groups['D']),
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']), 'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
'selected': len(selected), 'selected': len(selected),
'buckets': buckets, 'buckets': buckets,
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
'period_months': period_months,
} }
try: try:
+87 -29
View File
@@ -338,24 +338,35 @@ SOURCE_SCHEDULED = "scheduled"
SOURCE_COORDINATION = "coordination" 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 ( return (
Action.objects.filter(actionType=action_type, status__in=statuses) Action.objects.filter(actionType=action_type, status__in=statuses)
.filter( .filter(
models.Q(actionDateTime__gt=now_utc) models.Q(actionDateTime__gte=threshold)
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc) | models.Q(actionDateTime__isnull=True, finishDateTime__gte=threshold)
) )
.values_list("customer_id", "actionDateTime", "finishDateTime") .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 → زودترین تاریخِ آینده`` برای یک نوع اقدام.""" """نگاشت ``customer_id → زودترین تاریخِ آینده`` برای یک نوع اقدام."""
now_utc = _now(now).astimezone(dt_timezone.utc) now_utc = _now(now).astimezone(dt_timezone.utc)
result = {} result = {}
for customer_id, action_dt, finish_dt in _future_actions( 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(): ).iterator():
when = as_aware(action_dt) or as_aware(finish_dt) when = as_aware(action_dt) or as_aware(finish_dt)
if when is None: if when is None:
@@ -367,9 +378,13 @@ def _action_appointments(action_type, statuses, *, now=None):
def scheduled_visits_map(*, now=None): def scheduled_visits_map(*, now=None):
"""قرارهای «بازدید برنامه‌ریزی‌شده»: ``bazdid + waite`` با تاریخ آینده.""" """قرارهای «بازدید برنامه‌ریزی‌شده»: ``bazdid + waite`` با تاریخ آینده.
مقایسه در سطح **روز** است (``whole_day=True``): بازدیدی که برای «امروز»
گذاشته شده تا پایان امروز معتبر می‌ماند.
"""
return _action_appointments( 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): def manual_holds_map(*, now=None):
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**. """قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که:
«هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان
مشتری وجود داشته باشد. بنابراین:
* حذف هماهنگی ⇒ قرار خودکار بی‌اثر می‌شود، ۱. تاریخش از حالا جلوتر باشد،
* لغو هماهنگی (``cancell``) ⇒ بی‌اثر می‌شود، ۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد،
* هماهنگیِ گذشته ⇒ بی‌اثر می‌شود، ۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام
* کهنه‌ماندن ستون هیچ اثر مخربی ندارد. دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)، بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛
چون کاربر می‌تواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد. بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار»
می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است.
نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛
و کهنه‌ماندن ستون هیچ اثر مخربی ندارد.
""" """
now = _now(now) now = _now(now)
rows = ( rows = (
@@ -412,23 +429,44 @@ def manual_holds_map(*, now=None):
if not holds: if not holds:
return {} return {}
customer_ids = list(holds)
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با # مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
# تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار # تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
# دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده بودن تاریخ # گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده‌بودن تاریخ اقدام را
# اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بی‌اثر می‌شد. # بگذاریم، همین مورد (هدف اصلی این قابلیت) بی‌اثر می‌شد. پس فقط وضعیت مهم است.
# پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد). coordination_latest = dict(
# ``cancell`` حساب نمی‌شود و حذف رکورد هم خودکار بی‌اثر می‌کند.
active_coordination = set(
Action.objects.filter( Action.objects.filter(
customer_id__in=customer_ids,
actionType=Action.ActionType.HAMAHANGI, actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH), 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 other_latest = dict(
for customer_id, until in holds.items() Action.objects.filter(customer_id__in=customer_ids)
if customer_id in active_coordination and until is not None .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): def appointment_sources_map(*, now=None):
@@ -467,6 +505,26 @@ HOLD_DEFAULT_DAYS = 14
HOLD_MAX_DAYS = 183 # ≈ ۶ ماه 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): def end_of_local_day(moment):
"""پایان همان روز به وقت محلی (به‌صورت aware). """پایان همان روز به وقت محلی (به‌صورت aware).
+28 -7
View File
@@ -38,8 +38,17 @@
### قاعدهٔ طلایی: ستون، «مرجع» نیست ### قاعدهٔ طلایی: ستون، «مرجع» نیست
> `visit_hold_until` هیچ اعتباری به‌تنهایی ندارد. فقط وقتی اثر دارد که برای همان > `visit_hold_until` هیچ اعتباری به‌تنهایی ندارد. یک قرار دستی فقط وقتی اثر دارد
> مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد. > که **هر سه** شرط برقرار باشد:
>
> ۱. تاریخش از حالا جلوتر باشد،
> ۲. برای همان مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد،
> ۳. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدام دیگری
> (به‌ویژه «بازدید») ثبت شده باشد، قرار منتفی است.
شرط ۳ برای این است که «ثبت بازدید پس از هماهنگی» قرار را خودکار باطل کند.
بدون آن، قرار قدیمی تا تاریخش زنده می‌ماند و مشتری در فهرست «با قرار» می‌ماند،
هرچند بازدید جای هماهنگی را گرفته است.
نتیجهٔ این قاعده: نتیجهٔ این قاعده:
@@ -48,6 +57,7 @@
| حذف اقدام هماهنگی | قرار **خودکار بی‌اثر** می‌شود | | حذف اقدام هماهنگی | قرار **خودکار بی‌اثر** می‌شود |
| لغو هماهنگی (`cancell`) | بی‌اثر می‌شود | | لغو هماهنگی (`cancell`) | بی‌اثر می‌شود |
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید هم‌راستا می‌شود | | ویرایش تاریخ هماهنگی | قرار با تاریخ جدید هم‌راستا می‌شود |
| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بی‌اثر** می‌شود |
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** | | کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است. به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
@@ -58,15 +68,26 @@
تصمیم «مشتری در فهرست هست یا نه» از اجتماع سه منبع گرفته می‌شود: تصمیم «مشتری در فهرست هست یا نه» از اجتماع سه منبع گرفته می‌شود:
| منبع | تعریف | تاریخ قرار | | منبع | تعریف | تاریخ قرار | مرز زمانی |
|---|---|---| |---|---|---|---|
| `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) | | `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) | پایان امروز |
| `scheduled` | `bazdid + waite` با تاریخ آینده | `actionDateTime` | | `scheduled` | `bazdid + waite` | `actionDateTime` | **پایان روز** |
| `coordination` | `hamahangi` با `waite` یا `finish` و تاریخ آینده | `actionDateTime` (یا `finishDateTime`) | | `coordination` | `hamahangi` با `waite` یا `finish` | `actionDateTime` (یا `finishDateTime`) | **لحظه‌ای** |
تابع واحد: `visit_policy.next_appointment_map(now)` — **همان تابعی که اسکنر پیامک تابع واحد: `visit_policy.next_appointment_map(now)` — **همان تابعی که اسکنر پیامک
هم استفاده می‌کند**، پس صفحه و پیامک هرگز واگرا نمی‌شوند. هم استفاده می‌کند**، پس صفحه و پیامک هرگز واگرا نمی‌شوند.
**چرا مرز زمانیِ دو منبع فرق دارد؟**
* در `scheduled`، `actionDateTime` یک **قرارِ آینده** است که کارشناس با تقویم
روزانه انتخاب می‌کند؛ پس «قرار امروز» باید تا پایان امروز معتبر بماند. با
مقایسهٔ لحظه‌ای، بازدیدِ امروز بلافاصله پس از گذشتن ساعتش از فهرست ناپدید
می‌شد (باگی که گزارش شد).
* در `coordination`، `actionDateTime` **زمانِ تماس** است، نه قرار آینده. اگر آن
را هم تا پایان روز معتبر بگیریم، هر تماسِ امروز مشتری را تا آخر امروز از
گروه «قرار دستی» بیرون می‌برد و سناریوی اصلی (تماس امروز + قرار دو هفته بعد)
بی‌اثر می‌شد.
اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای
«بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود. «بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود.
+136 -7
View File
@@ -927,6 +927,80 @@ class VisitHoldTests(TestCase):
) )
self.assertNotIn(customer.pk, self._ids()) 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): def test_groups_and_filters(self):
@@ -937,23 +1011,31 @@ class VisitHoldTests(TestCase):
# b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد). # b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
Action.objects.create(customer=b, actionType='hamahangi', status='finish', 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)) set_visit_hold(b, timezone.now() + timedelta(days=20))
Action.objects.create(customer=c, actionType='bazdid', status='waite', Action.objects.create(customer=c, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=7)) actionDateTime=timezone.now() + timedelta(days=7))
# d: هم قرار دستی و هم بازدید برنامه‌ریزی‌شده. # d: بازدید برنامه‌ریزی‌شده (قدیمی‌تر) + قرار دستی (جدیدتر).
Action.objects.create(customer=d, actionType='hamahangi', status='finish', # ترتیب مهم است: اگر بازدید *پس از* هماهنگی ثبت شود، هماهنگی منتفی
actionDateTime=timezone.now(), finishDateTime=timezone.now()) # محسوب می‌شود (تست جداگانهٔ زیر). اینجا برعکس است تا گروه D پوشش داده شود.
set_visit_hold(d, timezone.now() + timedelta(days=25))
Action.objects.create(customer=d, actionType='bazdid', status='waite', Action.objects.create(customer=d, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=9)) 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'] counts = self._entezar().context['counts']
self.assertEqual(counts['uncoordinated'], 1) # a 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['both'], 1) # d
self.assertEqual(counts['total'], 4) self.assertEqual(counts['total'], 4)
self.assertEqual(counts['coordinated'], 3) self.assertEqual(counts['coordinated'], 3)
@@ -969,6 +1051,53 @@ class VisitHoldTests(TestCase):
self.assertEqual(self._ids('nonsense'), {a.pk}) self.assertEqual(self._ids('nonsense'), {a.pk})
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated') 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): def test_scanner_skips_customer_with_manual_hold(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue