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; } }
/* ---------- نوار آمار ---------- */
.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>
+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`` (فقط رو به جلو).
* اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیش‌فرض ۱۴ روز.
* اگر هماهنگی لغو شد (``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'))
+10 -2
View File
@@ -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
View File
@@ -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).