improve bazdid 2
This commit is contained in:
1 parent
534a84f977
commit
f8d768a724
6 files changed
+374
-92
No files matched your search
@@ -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 @@
|
||||
<div class="columns m-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">
|
||||
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'all' %}is-active{% endif %}">
|
||||
<span class="ez-num">{{ counts.total }}</span>
|
||||
<span class="ez-lbl">کل معوق</span>
|
||||
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
|
||||
title="همهٔ مشتریان عقبافتاده، با هر وضعیت قرار">
|
||||
<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 href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'uncoordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
|
||||
<span class="ez-lbl">بدون قرار</span>
|
||||
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
|
||||
title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است">
|
||||
<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 href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'coordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
|
||||
<span class="ez-lbl">با قرار</span>
|
||||
class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
|
||||
title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است">
|
||||
<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 href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'manual' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
|
||||
<span class="ez-lbl">قرار هماهنگی</span>
|
||||
class="ez-stat warning {% if view == 'manual' %}is-active{% endif %}"
|
||||
title="قرار تعویق که از فرم «هماهنگی» ثبت شده است">
|
||||
<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 href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'scheduled' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
|
||||
<span class="ez-lbl">بازدید برنامهریزیشده</span>
|
||||
class="ez-stat link {% if view == 'scheduled' %}is-active{% endif %}"
|
||||
title="اقدام «بازدید» با تاریخ آینده">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{# تفکیک مدت تأخیر در نمای جاری #}
|
||||
<div class="ez-legend">
|
||||
<span>تأخیر:</span>
|
||||
<span><b>{{ counts.buckets.lt30 }}</b> زیر ۳۰ روز</span>
|
||||
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰–۹۰ روز</span>
|
||||
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰–۱۸۰ روز</span>
|
||||
<span>میزان تأخیر در همین نما:</span>
|
||||
<span><b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز</span>
|
||||
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز</span>
|
||||
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز</span>
|
||||
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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:
|
||||
|
||||
+87
-29
@@ -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).
|
||||
|
||||
|
||||
@@ -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` **زمانِ تماس** است، نه قرار آینده. اگر آن
|
||||
را هم تا پایان روز معتبر بگیریم، هر تماسِ امروز مشتری را تا آخر امروز از
|
||||
گروه «قرار دستی» بیرون میبرد و سناریوی اصلی (تماس امروز + قرار دو هفته بعد)
|
||||
بیاثر میشد.
|
||||
|
||||
اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای
|
||||
«بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود.
|
||||
|
||||
|
||||
+136
-7
@@ -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
|
||||
|
||||
Reference in new issue
Block a user