improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

+381 -76
View File
@@ -19,19 +19,47 @@ from ..sms.phones import (normalize_primary_values,
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, last_visit_map, visit_due_at,
visit_due_date, visit_period_months)
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
visit_due_at, visit_due_date, visit_period_months)
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
# نماهای صفحهٔ «انتظار» (فیلتر ?view=).
# پیش‌فرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماس‌های
# امروز). سایر نماها با کارت‌ها/دراپ‌داون «نوع قرار» در دسترس‌اند.
# ``today`` یک نمای **مستقل** است: پایهٔ آن «عقب‌افتاده بودن» نیست — مشتری
# سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود.
ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination',
'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
ENTEZAR_DEFAULT_VIEW = 'today'
# نمای «نیازمند پیگیری» — برای بررسی «آیا این نما فعال است؟» در قالب/ویو.
ENTEZAR_FOLLOWUP_VIEW = 'today'
# تعداد ردیف در هر صفحه. فهرست کاری روزانه با یک نگاه مرور می‌شود، پس صفحه‌بندی
# دیرتر فعال شود.
ENTEZAR_PAGE_SIZE = 100
# فیلتر «میزان تأخیر» (?delay=). مرزها با «مدت تأخیر» (روز) مقایسه می‌شوند.
ENTEZAR_DELAYS = ('lt30', '30to90', '90to180', 'gt180')
# ستون‌های قابل مرتب‌سازی: نام فیلد ⇒ جهت پیش‌فرضِ اولین کلیک.
# ``oldest``/``newest``/``overdue`` مقادیر قدیمی‌اند و برای سازگاری با
# بوکمارک‌های قبلی نگه داشته شده‌اند (به ``last_visit``/``overdue`` نگاشت می‌شوند).
ENTEZAR_SORT_COLUMNS = {
'name': 'asc',
'status': 'asc',
'manual': 'desc',
'scheduled': 'desc',
'coordination': 'desc',
'last_visit': 'asc',
'overdue': 'desc',
'phone': 'asc',
'address': 'asc',
}
_ENTEZAR_LEGACY_SORTS = {
'oldest': ('last_visit', 'asc'),
'newest': ('last_visit', 'desc'),
'overdue': ('overdue', 'desc'),
}
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -359,9 +387,26 @@ def entezar(request):
view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower()
if view not in ENTEZAR_VIEWS:
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیش‌فرض، بدون خطا
sort = (request.GET.get('sort') or 'oldest').strip().lower()
if sort not in ENTEZAR_SORTS:
sort = 'oldest'
delay = (request.GET.get('delay') or '').strip().lower()
if delay not in ENTEZAR_DELAYS:
delay = '' # '' یعنی بدون فیلتر تأخیر
# مرتب‌سازی: ``?sort=<ستون>&dir=asc|desc``؛ مقادیر قدیمی هم پذیرفته می‌شوند.
# نکته: اگر ``dir`` صریح آمده باشد، مقدم است — وگرنه کلیک روی سرستون
# «تأخیر» با جهت صعودی، به‌خاطر نگاشت قدیمی، نادیده گرفته می‌شد.
raw_sort = (request.GET.get('sort') or '').strip().lower()
raw_dir = (request.GET.get('dir') or '').strip().lower()
if raw_sort in ENTEZAR_SORT_COLUMNS:
sort_field = raw_sort
if raw_dir in ('asc', 'desc'):
sort_dir = raw_dir
else:
sort_dir = 'desc' if raw_sort in _ENTEZAR_LEGACY_SORTS else \
ENTEZAR_SORT_COLUMNS[raw_sort]
elif raw_sort in _ENTEZAR_LEGACY_SORTS:
sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS[raw_sort]
else:
sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS['oldest']
searchStatus = True
full_path = request.get_full_path()
@@ -370,32 +415,47 @@ def entezar(request):
# دوره فقط یک بار خوانده می‌شود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
period_months = visit_period_months()
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
current_tz = timezone.get_current_timezone()
today_date = now.astimezone(current_tz).date()
due_cut_date = due_cut.astimezone(current_tz).date()
# تاریخ نمای «نیازمند پیگیری»: از تقویم شمسی می‌آید (``?fdate=1405/08/29``).
# خالی/نامعتبر ⇒ امروز. مقدار گذشته و آینده هر دو مجازند.
followup_date_raw = (request.GET.get('fdate') or '').strip()
followup_date = _parse_jalali_date(followup_date_raw) or today_date
followup_date_jalali = JalaliDate(followup_date).strftime('%Y/%m/%d')
is_followup_view = view == ENTEZAR_FOLLOWUP_VIEW
visits = last_visit_map(now=now)
sources = appointment_sources_map(now=now)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
def _apply_search(queryset):
"""فیلتر جست‌وجو؛ فیلد نامعتبر ⇒ بدون فیلتر (سازگاری با رفتار قبلی)."""
if not searchVal:
return queryset
if searchOn == 'address':
return queryset.filter(address__contains=searchVal)
if searchOn == 'name':
return queryset.filter(name_family__contains=searchVal)
if searchOn == 'phone':
return queryset.filter(
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
| Q(phone3__contains=searchVal)
)
if searchOn == 'code':
return queryset.filter(idCode__contains=searchVal)
return queryset
searchStatus = bool(searchVal) and searchOn in (
'address', 'name', 'phone', 'code')
# پایهٔ فهرست انتظار: فقط مشتریانی که دوره‌شان گذشته (کارت‌های آمار).
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
if searchVal:
if searchOn == 'address':
customers_objj = customers_objj.filter(address__contains=searchVal)
elif searchOn == 'name':
customers_objj = customers_objj.filter(name_family__contains=searchVal)
elif searchOn == 'phone':
customers_objj = customers_objj.filter(
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
| Q(phone3__contains=searchVal)
)
elif searchOn == 'code':
customers_objj = customers_objj.filter(idCode__contains=searchVal)
else:
searchStatus = False
else:
searchStatus = False
customers_objj = _apply_search(customers_objj)
# مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
stale_ids = [
@@ -431,12 +491,79 @@ def entezar(request):
'coordinated': coordinated_ids,
'all': stale_ids,
}
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
# این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید).
today_followups = followups_on_date(today_date, now=now)
# پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
# فقط در نمای پیگیری محاسبه می‌شود (کوئری اضافه برای بقیهٔ نماها لازم نیست).
overdue_map = overdue_followups(now=now, target_date=today_date) \
if is_followup_view else {}
overdue_oldest = min(overdue_map.values()) if overdue_map else None
# فیلتر «فقط عقب‌افتاده‌ها» (?missed=1) — همان نما با منبعِ دیگر.
show_missed = is_followup_view and \
(request.GET.get('missed') or '').strip().lower() in ('1', 'true', 'on')
if is_followup_view:
if show_missed:
# تاریخِ موعدِ ازدست‌رفته به‌صورت datetime تا با بقیهٔ مسیرها یکسان باشد.
followups = {
cid: {SOURCE_COORDINATION: as_aware(day)}
for cid, day in overdue_map.items()
}
elif followup_date == today_date:
followups = today_followups
else:
followups = followups_on_date(followup_date, now=now)
else:
followups = {}
def _followup_ids(mapping):
"""شناسه‌های واجد شرایط (فعال/لغو‌نشده/دارای نام + جست‌وجو) با ترتیب زمانی."""
if not mapping:
return []
eligible = _apply_search(
Customer.objects.filter(
id__in=list(mapping),
is_active=True,
laghv_bazdid=False,
name_family__isnull=False,
)
)
allowed = set(eligible.values_list('id', flat=True))
return sorted(
(cid for cid in mapping if cid in allowed),
key=lambda cid: (min(mapping[cid].values()), cid),
)
followup_ids = _followup_ids(followups) if is_followup_view else []
# شمارنده‌ها از همان مسیرِ فهرست می‌آیند تا «عدد هشدار = تعداد ردیف‌ها» بماند
# (مشتری غیرفعال/لغو‌شده/بی‌نام یا فیلترشده شمرده نمی‌شود).
today_followup_count = len(_followup_ids(today_followups))
overdue_followup_count = len(_followup_ids(
{cid: {SOURCE_COORDINATION: as_aware(day)} for cid, day in overdue_map.items()}
))
selected_map[ENTEZAR_FOLLOWUP_VIEW] = followup_ids
selected = selected_map[view]
# برای نمای پیگیری، فهرست مشتریان از همان شناسه‌های تأییدشده می‌آید
# (ممکن است در «عقب‌افتاده» نباشند).
if is_followup_view:
customers_objj = _apply_search(
Customer.objects.filter(id__in=followup_ids)
)
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
# این‌طور مشتریِ تازه‌معوق هم در بازهٔ کوتاه قرار می‌گیرد و عدد معنا پیدا می‌کند.
today = now.astimezone(timezone.get_current_timezone()).date()
# محاسبه روی کلِ نمای جاری انجام می‌شود تا شمارنده‌های بازه‌ها با فیلتر
# «میزان تأخیر» هم‌خوان باشند (اول نمای جاری، بعد فیلتر تأخیر).
today = today_date
overdue_days = {}
for customer_id in selected:
anchor = visits.get(customer_id)
@@ -448,42 +575,7 @@ def entezar(request):
base = due_cut_date
overdue_days[customer_id] = max((today - base).days, 0)
# مرتب‌سازی:
# oldest ⇒ قدیمی‌ترین بازدید اول (اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده)
if sort == 'overdue':
ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid))
customers_objj = customers_objj.filter(id__in=ordered_ids)
if ordered_ids:
# ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظه‌ای).
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
else:
customers_objj = customers_objj.order_by('id')
else:
# مرتب‌سازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکن‌القدمت.
ordered_ids = sorted(
selected,
key=lambda cid: (
(visits.get(cid) or due_cut),
cid,
),
reverse=(sort == 'newest'),
)
customers_objj = customers_objj.filter(id__in=ordered_ids)
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
# آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری).
# آمار «تأخیر» روی نمای جاری (قبل از اعمال فیلتر تأخیر).
buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0}
for days in overdue_days.values():
if days < 30:
@@ -495,17 +587,110 @@ def entezar(request):
else:
buckets['gt180'] += 1
# اعمال فیلتر «میزان تأخیر».
delay_ranges = {
'lt30': (0, 30),
'30to90': (30, 90),
'90to180': (90, 180),
'gt180': (180, None),
}
# فیلتر «میزان تأخیر» فقط برای نمای «عقب‌افتاده» معنا دارد؛ در نمای
# «پیگیری امروز» مشتری ممکن است اصلاً تأخیری نداشته باشد.
if delay and not is_followup_view:
low, high = delay_ranges[delay]
selected = [
cid for cid in selected
if overdue_days.get(cid, 0) >= low
and (high is None or overdue_days.get(cid, 0) < high)
]
# ---------- مرتب‌سازی (روی همهٔ ستون‌ها) ----------
# یک کوئری سبک برای فیلدهای Customer که برای مرتب‌سازی لازم است.
info = {
row['id']: row
for row in Customer.objects.filter(id__in=selected).values(
'id', 'name_family', 'phone1', 'address')
} if selected else {}
def _source_date(cid, key):
return (sources.get(cid) or {}).get(key)
def _sort_value(cid):
"""مقدار مرتب‌سازی برای هر ستون (تاریخ‌ها aware یا None)."""
if sort_field == 'name':
return (info.get(cid, {}).get('name_family') or '')
if sort_field == 'phone':
return (info.get(cid, {}).get('phone1') or '')
if sort_field == 'address':
return (info.get(cid, {}).get('address') or '')
if sort_field == 'status':
# ترتیب منطقی: بدون قرار ← قرار دستی ← بازدید ← هماهنگی ← چندقراره
entry = sources.get(cid) or {}
if not entry:
return 0
if len(entry) > 1:
return 4
if SOURCE_MANUAL in entry:
return 1
if SOURCE_SCHEDULED in entry:
return 2
return 3
if sort_field == 'manual':
return _source_date(cid, SOURCE_MANUAL)
if sort_field == 'scheduled':
return _source_date(cid, SOURCE_SCHEDULED)
if sort_field == 'coordination':
return _source_date(cid, SOURCE_COORDINATION)
if sort_field == 'overdue':
return overdue_days.get(cid, 0)
# last_visit: مشتریِ بدون بازدید، قدیمی‌ترین حساب می‌شود.
return visits.get(cid) or due_cut
if selected:
if is_followup_view and not raw_sort:
# در نمای پیگیری، ترتیب طبیعی «زودترین موعد پیگیری» است
# (ترتیب از ``_followup_ids`` آمده) — نه قدیمی‌ترین بازدید.
ordered_ids = list(selected)
else:
values = {cid: _sort_value(cid) for cid in selected}
with_value = [cid for cid in selected if values[cid] is not None]
without_value = [cid for cid in selected if values[cid] is None]
with_value.sort(key=lambda cid: (values[cid], cid),
reverse=(sort_dir == 'desc'))
# مقادیر خالی همیشه آخر می‌آیند (مستقل از جهت مرتب‌سازی) تا فهرست
# با ستون‌های «قرار دستی/بازدید/هماهنگی» بی‌معنا نشود.
ordered_ids = with_value + without_value
else:
ordered_ids = []
customers_objj = customers_objj.filter(id__in=ordered_ids)
if ordered_ids:
ordering = Case(
*[When(id=cid, then=Value(index))
for index, cid in enumerate(ordered_ids)],
output_field=models.IntegerField(),
)
customers_objj = customers_objj.order_by(ordering)
else:
customers_objj = customers_objj.order_by('id')
counts = {
'total': len(stale_ids),
'uncoordinated': len(selected_map['uncoordinated']),
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود؛
# چون یک مشتری می‌تواند چند منبع داشته باشد، جمع کارت‌ها ممکن است
# از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده می‌شود.
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود.
# یک مشتری می‌تواند چند منبع داشته باشد، پس جمع سه منبع می‌تواند از
# «تعیین تکلیف شده» بیشتر شود؛ جزئیات در دراپ‌داون «نوع قرار» است.
'manual': len(selected_map['manual']),
'scheduled': len(selected_map['scheduled']),
'coordination': len(selected_map['coordination']),
'coordinated': len(selected_map['coordinated']),
# کارت «نیازمند پیگیری امروز» — همیشه عدد امروز را نشان می‌دهد،
# مستقل از تاریخی که کاربر در تقویم انتخاب کرده است.
'followup_today': today_followup_count,
# پیگیری‌های موعدگذشته که هنوز تعیین تکلیف نشده‌اند (هشدار قرمز).
'followup_overdue': overdue_followup_count,
'selected': len(selected),
'view_total': len(selected_map[view]),
'buckets': buckets,
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
'period_months': period_months,
@@ -513,20 +698,118 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = بدون قرار.
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20)
paginator = Paginator(customers_objj, ENTEZAR_PAGE_SIZE)
page_number = request.GET.get('page', None)
customerHa = paginator.get_page(page_number)
# لینک صفحه‌بندی با حفظ همهٔ فیلترهای فعلی.
def _page_link(number):
params = request.GET.copy()
params['page'] = number
return '?' + params.urlencode()
page_prev = _page_link(customerHa.previous_page_number()) if customerHa.has_previous() else ''
page_next = _page_link(customerHa.next_page_number()) if customerHa.has_next() else ''
# ---------- لینک‌های حفظ‌کنندهٔ فیلترها ----------
def _qs(**overrides):
"""پارامترهای فعلی + تغییرهای داده‌شده (بدون page). None ⇒ حذف."""
params = request.GET.copy()
params.pop('page', None)
for key, value in overrides.items():
if value is None:
params.pop(key, None)
else:
params[key] = value
query = params.urlencode()
return '?' + query if query else '?'
view_links = {name: _qs(view=name) for name in ENTEZAR_VIEWS}
# کارت «نیازمند پیگیری امروز» همیشه به امروز می‌رود (تاریخ انتخابی پاک می‌شود).
view_links[ENTEZAR_FOLLOWUP_VIEW] = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None)
# ---------- ناوبری تقویم در نمای پیگیری ----------
def _followup_link(target_date):
return _qs(view=ENTEZAR_FOLLOWUP_VIEW,
fdate=JalaliDate(target_date).strftime('%Y/%m/%d'))
followup_nav = {
'prev': _followup_link(followup_date - datetime.timedelta(days=1)),
'next': _followup_link(followup_date + datetime.timedelta(days=1)),
'today': _followup_link(today_date),
'is_today': followup_date == today_date,
}
# لینک هشدار «عقب‌افتاده‌ها» و لینک بازگشت از آن حالت.
followup_overdue_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=1)
followup_ontime_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=None)
delay_links = {'ez_all': _qs(delay=None)}
delay_links.update({name: _qs(delay=name) for name in ENTEZAR_DELAYS})
sort_links = {}
for column, default_dir in ENTEZAR_SORT_COLUMNS.items():
# کلیک دوباره روی همان ستون، جهت را برمی‌گرداند.
next_dir = ('desc' if sort_dir == 'asc' else 'asc') \
if sort_field == column else default_dir
sort_links[column] = _qs(sort=column, dir=next_dir)
# سرستون‌های مرتب‌شدنی (به همان ترتیب ستون‌های جدول).
# در نمای «نیازمند پیگیری امروز» ستون «تأخیر» نمایش داده **نمی‌شود**:
# این فهرست دربارهٔ «موعد امروز» است، نه عقب‌افتادگی بازدید.
columns = [
('name', 'نام و نام خانوادگی', 21),
('status', 'وضعیت قرار', 12),
('manual', 'قرار دستی', 10),
('scheduled', 'بازدید برنامه‌ریزی‌شده', 10),
('coordination', 'هماهنگی', 10),
('last_visit', 'آخرین بازدید', 9),
('overdue', 'تأخیر', 8),
('phone', 'تلفن', 9),
('address', 'آدرس', 11),
]
if is_followup_view:
hidden_width = next(width for key, _, width in columns if key == 'overdue')
columns = [col for col in columns if col[0] != 'overdue']
# عرض ستون حذف‌شده به نام اضافه می‌شود تا مجموع همچنان ۱۰۰٪ بماند.
columns[0] = (columns[0][0], columns[0][1], columns[0][2] + hidden_width)
table_columns = [{'key': key, 'label': label, 'width': width}
for key, label, width in columns]
hidden_columns = ['overdue'] if is_followup_view else []
sort_header = [
{
'key': column,
'label': label,
'link': sort_links[column],
'active': sort_field == column,
'dir': sort_dir if sort_field == column else '',
}
for column, label in ((key, label) for key, label, _ in columns)
]
# لینک «حذف همهٔ فیلترها»: نمای پیش‌فرض (امروز)، بدون تأخیر/جست‌وجو/مرتب‌سازی.
clear_link = '?view=' + ENTEZAR_DEFAULT_VIEW
has_filters = bool(
delay or searchVal or sort_field != 'last_visit'
or sort_dir != 'asc'
# تاریخِ انتخاب‌شده یا حالت «فقط عقب‌افتاده‌ها» هم فیلتر است.
or (is_followup_view and followup_date != today_date)
or show_missed
)
# اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
appointment_rows = {}
for customer in customerHa.object_list:
entry = sources.get(customer.id) or {}
entry = dict(sources.get(customer.id) or {})
# در نمای پیگیری، تاریخ همان روز ملاک نمایش است (حتی اگر گذشته باشد و
# در ``appointment_sources_map`` که فقط آینده را می‌بیند، نباشد).
followup_entry = followups.get(customer.id) or {}
entry.update(followup_entry)
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
appointment_rows[customer.id] = {
@@ -537,14 +820,36 @@ def entezar(request):
'last_visit': visits.get(customer.id),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
'followup_at': (min(followup_entry.values())
if followup_entry else None),
}
return render(request, 'admin_panel/entezar.html', {
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
'searchVal': searchVal, 'searchOn': searchOn,
'view': view, 'counts': counts, 'appointment_rows': appointment_rows,
'sort': sort, 'sorts': ENTEZAR_SORTS,
'view_links': view_links, 'delay_links': delay_links,
'sort_links': sort_links, 'sort_header': sort_header,
'clear_link': clear_link,
'has_filters': has_filters,
'delay': delay, 'delays': ENTEZAR_DELAYS,
'sort': sort_field, 'sort_dir': sort_dir,
'sort_columns': ENTEZAR_SORT_COLUMNS,
'table_columns': table_columns,
'hidden_columns': hidden_columns,
'column_count': len(table_columns),
'page_prev': page_prev, 'page_next': page_next,
'views': ENTEZAR_VIEWS,
# نمای «نیازمند پیگیری»
'is_followup_view': is_followup_view,
'followup_view': ENTEZAR_FOLLOWUP_VIEW,
'followup_date': followup_date,
'followup_date_jalali': followup_date_jalali,
'followup_nav': followup_nav,
'show_missed': show_missed,
'followup_overdue_link': followup_overdue_link,
'followup_ontime_link': followup_ontime_link,
})