+
۱ نوع اقدام و مسئول
+
+
+
+
+
-
-
-
-
-
-
-
-
راجب کاری که در آینده میخواهید انجام دهید توضیحاتی را بنویسید
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
نتیجه کار یا پیگیری که انجام داده اید را بنویسید
-
-
-
-
+
+ {# ---------- گام ۲: زمانبندی ---------- #}
+
+
۲ زمانبندی
+
+
+
+
+
+
تاریخ انجام این وظیفه (ثبت در «لیست وظایف» همان روز).
+
+
+ {# فیلد «تا تاریخ پیگیری نشود» فقط برای «هماهنگی» #}
+
+
+
+
+
تا پایان این روز، مشتری در «انتظار بازدید» دیده نمیشود. خالی = ۱۴ روز.
+
+
+
+ {# راهنمای بازشونده — فقط برای هماهنگی #}
+
+
+
+
این تاریخ چه کاری میکند؟
+
میگوید «تا این تاریخ با این مشتری تماس نگیرید». در این بازه، مشتری از فهرست
+ انتظار بازدید پنهان میشود و روز بعد از آن
+ تاریخ خودکار برمیگردد. تاریخ تا
+ پایان همان روز معتبر است.
+
+
مثال
+
+ امروز ۱ آذر است. مشتری میگوید «سه هفتهٔ دیگر زنگ بزنید».
+ نوع = هماهنگی ← تیک «این کار انجام شده» ← «تا تاریخ پیگیری نشود» = ۲۲ آذر.
+ نتیجه: از ۱ تا ۲۲ آذر مشتری در فهرست نیست؛ از ۲۳ آذر خودکار برمیگردد.
+
+
+
نکات مهم
+
+ - خالی + تیک «انجام شد» ← سیستم خودش ۱۴ روز میگذارد.
+ - خالی + بدون تیک ← هیچ تعویقی ثبت نمیشود؛ مشتری در فهرست میماند.
+ - فقط تمدید میکند، کوتاه نمیکند: اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
+ قرار به ۳۰ آذر میرود؛ اما ۱۵ آذر نادیده گرفته میشود. برای کوتاهکردن،
+ هماهنگی را حذف و دوباره ثبت کنید.
+ - حذف یا لغو هماهنگی ← قرار بیاثر میشود و مشتری بلافاصله برمیگردد.
+ - سقف ۶ ماه است؛ بیشتر از آن با پیام خطا رد میشود.
+ - این فیلد تاریخ بازدید پرسنل نیست و بازدید حساب نمیشود؛
+ تاریخ آخرین بازدید و دورهٔ ۹ ماههٔ پیامک دست نمیخورد.
+ برای اعزام پرسنل از اقدام نوع بازدید استفاده کنید.
+ - در مدت تعویق، پیامک یادآوری ۹ ماههٔ بازدید ارسال نمیشود.
+
+
+
+
+
+ {# ---------- گام ۳: توضیحات ---------- #}
+
+
۳ توضیحات و وضعیت
+
+
+
+
+
+
+
+
+
+ {# هشدار/تأیید: فقط برای «هماهنگی» با تیکِ «انجام شده» #}
+
+
آیا مطمئن هستی که هماهنگی را انجام دادی؟
+
اگر هنوز هماهنگی نکردی، این تیک را بردار.
+
+
+
+
+
+
+
+
+
+
+
+
خالی بماند، زمان ثبت در نظر گرفته میشود.
+
+
+
+
+
+
+
+
+
-
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/admin_panel/templatetags/custom_tags.py b/admin_panel/templatetags/custom_tags.py
index a5b8c27..79d5a54 100644
--- a/admin_panel/templatetags/custom_tags.py
+++ b/admin_panel/templatetags/custom_tags.py
@@ -20,6 +20,20 @@ def toJalaliDT(value):
def has_group(user, group_name):
return user.groups.filter(name=group_name).exists()
+
+def row_for(mapping, key):
+ """مقدار یک نگاشت با کلید دلخواه (برای دسترسی به دیکشنری در قالب).
+
+ ``{{ appointment_rows|row_for:customer.id }}`` ⇒ مقدار مربوط به همان مشتری،
+ یا ``None`` اگر نبود. (جایگزین سبکِ ``.get()`` در قالبهای Django.)
+ """
+ if not mapping:
+ return None
+ try:
+ return mapping.get(key)
+ except AttributeError:
+ return None
+
@register.simple_tag
def customer_phones(customer):
"""شمارههای مشتری برای ستون اطلاعات مشتری.
@@ -35,4 +49,5 @@ def customer_phones(customer):
register.filter('has_group', has_group)
register.filter('toJalali', toJalali)
-register.filter('toJalaliDT', toJalaliDT)
\ No newline at end of file
+register.filter('toJalaliDT', toJalaliDT)
+register.filter('row_for', row_for)
diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py
index 99274e9..f9bd992 100644
--- a/admin_panel/views/actions.py
+++ b/admin_panel/views/actions.py
@@ -12,8 +12,10 @@ from django.utils import timezone
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
- as_aware, normalize_finish_datetime,
- refresh_last_bazdid, validate_action_payload)
+ as_aware, clear_visit_hold, default_hold_until,
+ normalize_finish_datetime, normalize_hold_until,
+ refresh_last_bazdid, set_visit_hold,
+ validate_action_payload)
@login_required(login_url='/panel/p_login/')
@@ -80,6 +82,8 @@ def _action_payload_from_request(request):
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
+ # فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
+ 'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
@@ -87,6 +91,26 @@ def _action_payload_from_request(request):
}
+def _apply_visit_hold(customer_obj, payload):
+ """نوشتن/پاککردن «قرار بعدی» بر اساس اقدام ثبت/ویرایششده.
+
+ قواعد:
+ * فقط برای اقدام نوع «هماهنگی» معنا دارد.
+ * اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو).
+ * اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیشفرض ۱۴ روز.
+ * اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک میشود.
+ """
+ if payload['actionType'] != Action.ActionType.HAMAHANGI:
+ return
+ raw = payload.get('holdRaw')
+ if raw is not None:
+ target = normalize_hold_until(raw)
+ set_visit_hold(customer_obj, target)
+ return
+ if payload['status'] == Action.Status.FINISH:
+ set_visit_hold(customer_obj, default_hold_until())
+
+
def _reject(request, error, redirect_to):
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
messages.error(request, '؛ '.join(error.messages))
@@ -98,6 +122,9 @@ def customerNewAction(request ,c_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
+ # اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته).
+ if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
+ normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
@@ -119,6 +146,8 @@ 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)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
@@ -147,6 +176,8 @@ def customerEditAction(request ,c_id, action_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
+ if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
+ normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(
request, error,
@@ -174,6 +205,12 @@ def customerEditAction(request ,c_id, action_id):
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت همراستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
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)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py
index 5f8f23d..60792ac 100644
--- a/admin_panel/views/customers.py
+++ b/admin_panel/views/customers.py
@@ -2,7 +2,7 @@ import datetime
import os
from django.db import models
-from django.db.models import Sum, Q
+from django.db.models import Sum, Q, Case, When, Value
from django.shortcuts import redirect, render, get_object_or_404
from django.core.paginator import Paginator
from django.utils import timezone
@@ -18,7 +18,19 @@ from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
-from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
+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)
+
+# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیشفرض = فقط «بدون قرار» تا رفتار
+# قبلی صفحه برای کاربران حفظ شود.
+ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all')
+ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
+# حالتهای مرتبسازی (?sort=):
+# oldest ⇒ قدیمیترین بازدید اول (پیشفرض/اولویت کارشناس)
+# newest ⇒ جدیدترین بازدید اول
+# overdue ⇒ بیشترین تأخیر اول
+ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -329,32 +341,59 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/')
def entezar(request):
- """فهرست «منتظر بازدید».
+ """فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار».
- منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کششدهٔ
- ``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
- از همان قالب پیامک خوانده میشود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
- در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده میشود.
+ منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی»
+ ``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامهریزیشده،
+ هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
+
+ * ``A`` — معوق بدون هیچ قرار (پیشفرض نمایش؛ رفتار قبلی صفحه)
+ * ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی)
+ * ``C`` — معوق با بازدید برنامهریزیشده یا هماهنگی در انتظار
+ * ``D`` — معوق با بیش از یک منبعِ قرار
+
+ فیلتر با ``?view=`` و قابل ترکیب با جستوجوی آدرس است.
"""
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
+ 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'
searchStatus = True
full_path = request.get_full_path()
now = timezone.now()
due_cut = visit_due_at(now=now)
+ # دوره فقط یک بار خوانده میشود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
+ period_months = visit_period_months()
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
visits = last_visit_map(now=now)
- appointments = next_appointment_map(now=now)
+ sources = appointment_sources_map(now=now)
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
- if searchOn == 'address':
- customers_objj = customers_objj.filter(address__contains=searchVal)
+ # جستوجو: نام، آدرس، تلفن (همه شمارهها) و کد شناسایی.
+ 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
@@ -363,28 +402,142 @@ def entezar(request):
customer_id for customer_id in customers_objj.values_list('id', flat=True)
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
]
- customers_objj = customers_objj.filter(id__in=stale_ids)
- # «قرار بعدی» (هماهنگی توافقشده یا بازدید برنامهریزیشده) یعنی این مشتری
- # در این فهرست جایی ندارد تا آن تاریخ.
- pending_ids = [cid for cid in stale_ids if cid in appointments]
- if pending_ids:
- customers_objj = customers_objj.exclude(id__in=pending_ids)
+ # تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه.
+ groups = {'A': [], 'B': [], 'C': [], 'D': []}
+ for customer_id in stale_ids:
+ customer_sources = sources.get(customer_id) or {}
+ manual = SOURCE_MANUAL in customer_sources
+ scheduled = (SOURCE_SCHEDULED in customer_sources
+ or SOURCE_COORDINATION in customer_sources)
+ if manual and scheduled:
+ groups['D'].append(customer_id)
+ elif manual:
+ groups['B'].append(customer_id)
+ elif scheduled:
+ groups['C'].append(customer_id)
+ else:
+ groups['A'].append(customer_id)
- customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
+ selected = {
+ 'uncoordinated': groups['A'],
+ 'manual': groups['B'] + groups['D'],
+ 'scheduled': groups['C'] + groups['D'],
+ 'coordinated': groups['B'] + groups['C'] + groups['D'],
+ 'all': stale_ids,
+ }[view]
+
+ # «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
+ # مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
+ # اینطور مشتریِ تازهمعوق هم در بازهٔ کوتاه قرار میگیرد و عدد معنا پیدا میکند.
+ today = now.astimezone(timezone.get_current_timezone()).date()
+ overdue_days = {}
+ for customer_id in selected:
+ anchor = visits.get(customer_id)
+ if anchor is not None:
+ due = visit_due_date(anchor, months=period_months)
+ base = due.astimezone(timezone.get_current_timezone()).date()
+ else:
+ # بدون بازدید: مقطع دوره (تاریخ نصب خیلی قدیمی، عدد را بیمعنا میکند).
+ 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:
+ buckets['lt30'] += 1
+ elif days < 90:
+ buckets['30to90'] += 1
+ elif days < 180:
+ buckets['90to180'] += 1
+ else:
+ buckets['gt180'] += 1
+
+ counts = {
+ 'total': len(stale_ids),
+ 'uncoordinated': len(groups['A']),
+ 'manual': len(groups['B']),
+ 'scheduled': len(groups['C']),
+ 'both': len(groups['D']),
+ 'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
+ 'selected': len(selected),
+ 'buckets': buckets,
+ }
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
- entezar_bazdid.value = len(stale_ids) - len(pending_ids)
+ # کارت داشبورد = «کارِ فوری» = بدون قرار.
+ entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
except CacheTable.DoesNotExist:
pass
- paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
+ paginator = Paginator(customers_objj, 20)
page_number = request.GET.get('page', None)
customerHa = paginator.get_page(page_number)
- return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
+ # اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
+ appointment_rows = {}
+ for customer in customerHa.object_list:
+ entry = sources.get(customer.id) or {}
+ appointment_rows[customer.id] = {
+ 'manual': entry.get(SOURCE_MANUAL),
+ 'scheduled': entry.get(SOURCE_SCHEDULED),
+ 'coordination': entry.get(SOURCE_COORDINATION),
+ 'overdue_days': overdue_days.get(customer.id, 0),
+ 'last_visit': visits.get(customer.id),
+ 'group': (
+ 'D' if (SOURCE_MANUAL in entry and (
+ SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry))
+ else 'B' if SOURCE_MANUAL in entry
+ else 'C' if (SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry)
+ else 'A'
+ ),
+ }
+
+ 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,
+ 'views': ENTEZAR_VIEWS,
+ })
@login_required(login_url='/panel/p_login/')
diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py
index 1a0edfe..916ae01 100644
--- a/admin_panel/visit_policy.py
+++ b/admin_panel/visit_policy.py
@@ -295,57 +295,23 @@ def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
def next_appointment_map(*, now=None):
- """نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
+ """نگاشت ``customer_id → زودترین «قرار بعدی» آینده``.
- منابع قرار (فقط آینده):
- * ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
- * ``bazdid`` با وضعیت ``waite`` (بازدید برنامهریزیشده).
+ اجتماع سه منبع (همه با تاریخ آینده):
- تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
- فقط برای ``entezar`` و اسکنر پیامک استفاده میشود و هرگز در «آخرین بازدید»
- دخالت نمیکند.
+ * ``manual`` — ستون ``Customer.visit_hold_until`` (با اعتبارسنجی)،
+ * ``scheduled`` — ``bazdid + waite`` (بازدید برنامهریزیشده)،
+ * ``coordination`` — ``hamahangi`` با وضعیت ``waite`` یا ``finish`` که
+ تاریخش آینده است (سازگاری با داده و رفتار قبلی).
+
+ این تابع تصمیم «مشتری در فهرست انتظار هست یا نه» و همچنین اسکنر پیامک را
+ تغذیه میکند؛ «آخرین بازدید» هیچگاه از اینجا خوانده نمیشود.
"""
now = _now(now)
- # همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
- # دوباره با منطقهٔ محلی adapt نکند.
- now_utc = now.astimezone(dt_timezone.utc)
-
- # چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینهساز SQLite از ایندکس
- # ``act_visit_idx`` استفاده نمیکند و کوئری ~۱۹۳ms طول میکشد. با تقسیم به
- # کوئریهای ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا میشوند.
- # (از ``union()`` استفاده نمیکنیم چون در این ترکیب، ردیفها را از دست میدهد.)
- hamahangi = Action.objects.filter(
- actionType=Action.ActionType.HAMAHANGI,
- status__in=(Action.Status.WAITE, Action.Status.FINISH),
- ).filter(
- models.Q(actionDateTime__gt=now_utc)
- | models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
- ).values_list("customer_id", "actionDateTime", "finishDateTime")
-
- planned_visit = Action.objects.filter(
- actionType=Action.ActionType.BAZDID,
- status=Action.Status.WAITE,
- actionDateTime__gt=now_utc,
- ).values_list("customer_id", "actionDateTime")
-
- combined = {}
-
- def _remember(customer_id, when):
- when = as_aware(when)
- if when is None:
- return
- current = combined.get(customer_id)
- if current is None or when < current:
- combined[customer_id] = when
-
- for customer_id, action_dt, finish_dt in hamahangi.iterator():
- # تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
- _remember(customer_id, action_dt or finish_dt)
-
- for customer_id, action_dt in planned_visit.iterator():
- _remember(customer_id, action_dt)
-
- return combined
+ return {
+ customer_id: min(sources.values())
+ for customer_id, sources in appointment_sources_map(now=now).items()
+ }
def next_appointment(customer, *, now=None):
@@ -355,6 +321,134 @@ def next_appointment(customer, *, now=None):
return next_appointment_map(now=now).get(customer.pk)
+# ---------------------------------------------------------------------------
+# منابع «قرار بعدی» بهتفکیک — برای صفحهٔ انتظار و آمار
+# ---------------------------------------------------------------------------
+# سه منبع وجود دارد و هر سه با هم «قرار بعدی» را میسازند:
+#
+# ۱. ``manual`` — ستون ``Customer.visit_hold_until`` (تعویق دستی از فرم هماهنگی)
+# ۲. ``scheduled`` — اقدام بازدید با وضعیت ``waite`` و تاریخ آینده
+# ۳. ``coordination`` — اقدام هماهنگیِ در انتظار (کارشناس تیک «انجام شد» نزده)
+#
+# تفکیک اینها فقط برای نمایش/آمار است؛ تصمیم «مشتری در فهرست هست یا نه» را
+# همان ``next_appointment_map`` میگیرد (اجتماع هر سه).
+
+SOURCE_MANUAL = "manual"
+SOURCE_SCHEDULED = "scheduled"
+SOURCE_COORDINATION = "coordination"
+
+
+def _future_actions(action_type, statuses, *, 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)
+ )
+ .values_list("customer_id", "actionDateTime", "finishDateTime")
+ )
+
+
+def _action_appointments(action_type, statuses, *, now=None):
+ """نگاشت ``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
+ ).iterator():
+ when = as_aware(action_dt) or as_aware(finish_dt)
+ if when is None:
+ continue
+ current = result.get(customer_id)
+ if current is None or when < current:
+ result[customer_id] = when
+ return result
+
+
+def scheduled_visits_map(*, now=None):
+ """قرارهای «بازدید برنامهریزیشده»: ``bazdid + waite`` با تاریخ آینده."""
+ return _action_appointments(
+ Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now,
+ )
+
+
+def coordination_holds_map(*, now=None):
+ """قرارهای «هماهنگی» با تاریخ آینده.
+
+ هر دو وضعیت ``waite`` و ``finish`` در نظر گرفته میشوند (سازگاری با رفتار
+ قبلی). حالت ``waite`` یعنی کارشناس هنوز تیک «انجام شد» نزده، و ``finish``
+ یعنی تماس انجام شده و قرار برای آینده گذاشته شده است.
+ """
+ return _action_appointments(
+ Action.ActionType.HAMAHANGI,
+ (Action.Status.WAITE, Action.Status.FINISH),
+ now=now,
+ )
+
+
+def manual_holds_map(*, now=None):
+ """قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
+
+ قاعدهٔ قطعی: ستون بهتنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام
+ «هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان
+ مشتری وجود داشته باشد. بنابراین:
+
+ * حذف هماهنگی ⇒ قرار خودکار بیاثر میشود،
+ * لغو هماهنگی (``cancell``) ⇒ بیاثر میشود،
+ * هماهنگیِ گذشته ⇒ بیاثر میشود،
+ * کهنهماندن ستون هیچ اثر مخربی ندارد.
+
+ مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)،
+ چون کاربر میتواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد.
+ """
+ now = _now(now)
+ rows = (
+ Customer.objects.filter(visit_hold_until__gt=now)
+ .values_list("id", "visit_hold_until")
+ .iterator()
+ )
+ holds = {customer_id: as_aware(until) for customer_id, until in rows}
+ if not holds:
+ return {}
+
+ # مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
+ # تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار
+ # دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آینده بودن تاریخ
+ # اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بیاثر میشد.
+ # پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد).
+ # ``cancell`` حساب نمیشود و حذف رکورد هم خودکار بیاثر میکند.
+ active_coordination = set(
+ Action.objects.filter(
+ actionType=Action.ActionType.HAMAHANGI,
+ status__in=(Action.Status.WAITE, Action.Status.FINISH),
+ ).values_list("customer_id", flat=True)
+ )
+ return {
+ customer_id: until
+ for customer_id, until in holds.items()
+ if customer_id in active_coordination and until is not None
+ }
+
+
+def appointment_sources_map(*, now=None):
+ """نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
+
+ برای صفحهٔ انتظار و آمار استفاده میشود؛ تصمیم «خارج از فهرست» را
+ :func:`next_appointment_map` میگیرد که اجتماع همینهاست.
+ """
+ now = _now(now)
+ merged = {}
+ for source, values in (
+ (SOURCE_MANUAL, manual_holds_map(now=now)),
+ (SOURCE_SCHEDULED, scheduled_visits_map(now=now)),
+ (SOURCE_COORDINATION, coordination_holds_map(now=now)),
+ ):
+ for customer_id, when in values.items():
+ merged.setdefault(customer_id, {})[source] = when
+ return merged
+
+
# نام قدیمی برای سازگاری با فراخوانیهای موجود.
def active_deferrals(*, now=None):
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقبرو)."""
@@ -366,6 +460,100 @@ def active_deferral(customer, *, now=None):
return next_appointment(customer, now=now)
+# ---------------------------------------------------------------------------
+# نوشتن «قرار دستی» روی مشتری
+# ---------------------------------------------------------------------------
+HOLD_DEFAULT_DAYS = 14
+HOLD_MAX_DAYS = 183 # ≈ ۶ ماه
+
+
+def end_of_local_day(moment):
+ """پایان همان روز به وقت محلی (بهصورت aware).
+
+ تصمیم محصولی: «قرار تا ۱ آذر» یعنی تا **آخر** ۱ آذر، نه تا ساعت ثبت.
+ اگر صرفاً ``actionDateTime`` را ذخیره کنیم، قرارِ «امروز» بلافاصله منقضی
+ میشد و مشتری همان لحظه به فهرست برمیگشت.
+ """
+ moment = as_aware(moment)
+ if moment is None:
+ return None
+ local = moment.astimezone(timezone.get_current_timezone())
+ end_of_day = datetime.combine(local.date(), time(23, 59, 59))
+ try:
+ return timezone.make_aware(end_of_day, timezone.get_current_timezone())
+ except Exception:
+ return timezone.make_aware(
+ end_of_day, timezone.get_current_timezone(), is_dst=False,
+ )
+
+
+def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
+ """پیشفرض «قرار بعدی»: پایان روزِ ``days`` روز بعد."""
+ base = _now(now) + relativedelta.relativedelta(days=days)
+ return end_of_local_day(base)
+
+
+def set_visit_hold(customer, until, *, now=None):
+ """ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد مینویسد.
+
+ قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را میگیرد که
+ ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
+ خروجی: ``True`` اگر نوشته شد.
+ """
+ if customer is None or customer.pk is None:
+ return False
+ target = end_of_local_day(until)
+ if target is None:
+ return False
+ current = as_aware(customer.visit_hold_until)
+ if current is not None and current >= target:
+ return False
+ Customer.objects.filter(pk=customer.pk).update(
+ visit_hold_until=target, visit_hold_set_at=_now(now),
+ )
+ customer.visit_hold_until = target
+ return True
+
+
+def clear_visit_hold(customer):
+ """پاککردن قرار دستی (مثلاً وقتی بازدید واقعی انجام شد)."""
+ if customer is None or customer.pk is None:
+ return False
+ if customer.visit_hold_until is None and customer.visit_hold_set_at is None:
+ return False
+ Customer.objects.filter(pk=customer.pk).update(
+ visit_hold_until=None, visit_hold_set_at=None,
+ )
+ customer.visit_hold_until = None
+ customer.visit_hold_set_at = None
+ return True
+
+
+def normalize_hold_until(raw, *, now=None):
+ """اعتبارسنجی و نرمالسازی «قرار بعدی» از ورودی فرم.
+
+ قواعد:
+ * مقدار خالی ⇒ ``None`` (بدون تعویق).
+ * تاریخ باید در آینده یا امروز باشد (پایان روز پذیرفته است).
+ * حداکثر ``HOLD_MAX_DAYS`` روز جلوتر (≈ ۶ ماه) — جلوگیری از «تعویق بیپایان».
+
+ خطای ``ValidationError`` با پیام فارسی برمیگرداند تا در ویو نشان داده شود.
+ """
+ moment = as_aware(raw)
+ if moment is None:
+ return None
+ target = end_of_local_day(moment)
+ today_end = end_of_local_day(_now(now))
+ if target < today_end:
+ raise ValidationError("تاریخ قرار نمیتواند پیش از امروز باشد.")
+ limit = today_end + relativedelta.relativedelta(days=HOLD_MAX_DAYS)
+ if target > limit:
+ raise ValidationError(
+ f"قرار بعدی حداکثر میتواند {HOLD_MAX_DAYS} روز (۶ ماه) جلوتر باشد."
+ )
+ return target
+
+
# ---------------------------------------------------------------------------
# دورهٔ بازدید (منبع واحد)
# ---------------------------------------------------------------------------
@@ -399,6 +587,9 @@ def visit_due_date(anchor, *, months=None):
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
+
+ توجه: ``months`` را در حلقهها **حتماً** پاس بدهید؛ وگرنه برای هر لنگر یک
+ کوئری خواندنِ قالب پیامک زده میشود (باگ N+1).
"""
anchor = as_aware(anchor)
if anchor is None:
diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md
new file mode 100644
index 0000000..96d6713
--- /dev/null
+++ b/docs/visit_hold_entezar.md
@@ -0,0 +1,468 @@
+# «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد
+
+این سند رفتار نهایی صفحهٔ `/panel/entezar/`، فیلد «تا تاریخ پیگیری نشود» در فرم
+اقدام، و قواعد محاسبه را توضیح میدهد. هدف: بعد از این تغییر، **هماهنگیِ انجامشده
+هم** مشتری را تا تاریخ توافق از فهرست خارج کند و کاربر بتواند بازدیدهای
+«هماهنگشده» را از «هماهنگنشده» جدا ببیند.
+
+---
+
+## ۱. صورت مسئله
+
+پیش از این تغییر، فقط دو حالت مشتری را از فهرست خارج میکرد:
+
+1. اقدام `bazdid` با وضعیت `waite` و تاریخ آینده (بازدید برنامهریزیشده)
+2. اقدام `hamahangi` با تاریخ **آینده** در فیلد تاریخ اقدام
+
+اما سناریوی واقعی کارشناس این بود:
+
+> «همین حالا با مشتری تماس گرفتم، توافق کردیم هفتهٔ بعد بیاییم، و تیک *انجام شد* را
+> زدم.»
+
+در این حالت، فیلد «تاریخ اقدام» روی **همین حالا** مینشیند، پس هیچ قرار آیندهای
+وجود نداشت و مشتری در فهرست میماند و کارشناس دیگری دوباره تماس میگرفت.
+
+**ریشه:** «تاریخ تماس» و «تاریخ قرار بعدی» دو مفهوم متفاوتاند ولی در یک فیلد
+(`actionDateTime`) جمع شده بودند.
+
+---
+
+## ۲. راهحل: ستون «قرار بعدی»
+
+دو ستون به مدل `Customer` اضافه شد:
+
+| ستون | نوع | معنا |
+|---|---|---|
+| `visit_hold_until` | `DateTimeField(null=True)` | تا **پایان** این روز پیگیری لازم نیست |
+| `visit_hold_set_at` | `DateTimeField(null=True)` | زمان ثبت، برای حسابرسی |
+
+### قاعدهٔ طلایی: ستون، «مرجع» نیست
+
+> `visit_hold_until` هیچ اعتباری بهتنهایی ندارد. فقط وقتی اثر دارد که برای همان
+> مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد.
+
+نتیجهٔ این قاعده:
+
+| رویداد | اثر روی قرار |
+|---|---|
+| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
+| لغو هماهنگی (`cancell`) | بیاثر میشود |
+| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
+| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
+
+به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
+
+---
+
+## ۳. سه منبع «قرار بعدی»
+
+تصمیم «مشتری در فهرست هست یا نه» از اجتماع سه منبع گرفته میشود:
+
+| منبع | تعریف | تاریخ قرار |
+|---|---|---|
+| `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) |
+| `scheduled` | `bazdid + waite` با تاریخ آینده | `actionDateTime` |
+| `coordination` | `hamahangi` با `waite` یا `finish` و تاریخ آینده | `actionDateTime` (یا `finishDateTime`) |
+
+تابع واحد: `visit_policy.next_appointment_map(now)` — **همان تابعی که اسکنر پیامک
+هم استفاده میکند**، پس صفحه و پیامک هرگز واگرا نمیشوند.
+
+اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای
+«بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود.
+
+---
+
+## ۴. فرم ثبت اقدام (UI)
+
+در مودال «ایجاد وظیفه» (`customerActionInc.html`):
+
+| مورد | رفتار |
+|---|---|
+| نمایش فیلد | فقط وقتی نوع اقدام = **هماهنگی** انتخاب شود |
+| برچسب | «تا تاریخ پیگیری نشود» |
+| کنترل | تقویم شمسی (همان `pDatepicker` پروژه) |
+| پیشفرض | اگر خالی بماند و تیک «انجام شد» زده شود ⇒ **+۱۴ روز** |
+| سقف | **۶ ماه** (اعتبارسنجی سروری) |
+| حالت ویرایش | فیلد با نوع اقدام همراستا میشود (نمایش/پنهان) |
+
+نکتهٔ مهم: فیلد «تاریخ و ساعتی که کار باید انجام شود» (`dateTimeAnjam`) همچنان
+«زمان تماس» است و دست نخورده باقی میماند. دو مفهوم، دو فیلد.
+
+### ۴.۱ چیدمان مودال (mobile first)
+
+مودال به سه گام شمارهدار تقسیم شده تا کاربر بداند چه چیزی را کجا وارد کند:
+
+| گام | محتوا |
+|---|---|
+| ۱ | نوع اقدام + مسئول (توسط) — کنار هم در دسکتاپ، زیر هم در موبایل |
+| ۲ | زمانبندی: تاریخ انجام، «تا تاریخ پیگیری نشود»، و راهنمای بازشونده |
+| ۳ | توضیحات، تیک «انجام شده»، تاریخ اتمام و نتیجه |
+
+نکات پیادهسازی:
+
+* پیشوند همهٔ کلاسها `ax-` و همهٔ انتخابگرها زیر `#ActionBox` است ⇒ هیچ استایلی
+ به سایر صفحات نشت نمیکند.
+* روی موبایل: کارت تمامعرض (`calc(100% - 1rem)`)، بدنهٔ اسکرولشونده، هدف لمسی
+ حداقل ۲٫۶rem، و دکمههای پاصفحه تمامعرض.
+* روی دسکتاپ (≥۷۶۸px): شبکهٔ دوستونه، عرض حداکثر ۴۴rem.
+* سرصفحه نام مشتری را نشان میدهد تا کاربر بداند برای چه کسی وظیفه میسازد.
+* **همان تقویم شمسی قبلی** استفاده میشود: `dateTimePickerAnjam`،
+ `dateTimePickerFinish` و `dateTimePickerHoldUntil` با `pDatepicker` و
+ `calendar.persian.locale = 'fa'`. هیچ کتابخانهٔ جدیدی اضافه نشده است.
+* **نام کلاس فیلد باید با سلکتور JS یکی باشد.** هر فیلد تاریخ دو المان دارد:
+ ورودی نمایشی (با کلاس `dateTimePickerXxx`) و ورودی مخفیِ ارسال (با کلاس
+ `dateTimePickerXxx-alt`). اگر این نامها همخوانی نداشته باشند،
+ `pDatepicker` روی مجموعهٔ خالی اجرا میشود، `undefined` برمیگرداند و
+ `setDate()` با `TypeError` کل هندلر را میشکند — نتیجه: تقویم باز نمیشود و
+ بقیهٔ منطق (پنهانکردن فیلدها، برداشتن تیک خودکار) هم اجرا نمیشود.
+ دو تست اختصاصی
+ (`test_every_picker_selector_matches_markup` و
+ `test_every_alt_field_class_exists_in_markup`) همین را قفل کردهاند.
+* `altField` با فرمت پیشفرض `unix` مقدار میدهد؛ بنابراین `visitHoldUntil`
+ یک timestamp میلیثانیهای است که `_parse_form_datetime` آن را میخواند.
+* تقویم به `body` اضافه میشود (نه داخل مودال) ⇒ اسکرول بدنهٔ مودال آن را نمیبرد.
+
+### ۴.۲ هشدار زمانبندی و پیشفرض «انجام شده» (فقط هماهنگی)
+
+چون در عمل «ثبت هماهنگی» یعنی «همین حالا با مشتری تماس گرفتم»، انتخاب نوع
+**هماهنگی** بهطور خودکار تیک «این کار انجام شده است» را میزند (و فیلدهای
+تاریخ اتمام و نتیجه را نمایش میدهد). برای جلوگیری از ثبتِ ناخواسته، دو لایه
+محافظ اضافه شده است:
+
+**۱) هشدار همیشگی** — بهمحض تیکخوردن و تا وقتی نوع هماهنگی است، یک کادر
+کهربایی زیر تیک نمایش داده میشود:
+
+> آیا مطمئن هستی که هماهنگی را انجام دادی؟
+> اگر هنوز هماهنگی نکردی، این تیک را بردار.
+
+**۲) تأیید در لحظهٔ ثبت** — اگر کاربر روی «ثبت» بزند و نوع هماهنگی و تیک
+«انجام شده» فعال باشد، ارسال متوقف میشود و همان کادر برجسته شده و دو دکمه
+نشان میدهد:
+
+| دکمه | نتیجه |
+|---|---|
+| بله، هماهنگی انجام شد | فرم ارسال میشود (هماهنگی با وضعیت `finish` ثبت میشود) |
+| نه، تیک را بردار | تیک برداشته میشود، فیلدهای اتمام پنهان میشوند و ارسال نمیشود |
+
+قواعد ایمنی:
+
+* **فقط هماهنگی**: سایر انواع اقدام (بازدید، نصب، وصول، تماس…) هیچ تیک
+ خودکار و هیچ تأییدی ندارند و رفتارشان دقیقاً مثل قبل است.
+* **تغییر نوع = برداشتن تیک خودکار**: اگر سیستم تیک زده باشد و کاربر نوع را
+ به غیر هماهنگی تغییر دهد، تیک برداشته میشود (تا ناخواسته `last_bazdid_dt`
+ یا وضعیت «انجام شده» ثبت نشود). اگر کاربر خودش تیک زده باشد، دستنخورده
+ میماند.
+* **حالت ویرایش**: تأیید گرفته نمیشود (کاربر فقط رکورد موجود را اصلاح میکند)؛
+ اما کادر هشدار برای هماهنگیِ انجامشده بهصورت اطلاعاتی نمایش داده میشود.
+* **باز شدن مجدد مودال**: تیک، هشدار و وضعیت تأیید همیشه ریست میشوند.
+
+### ۴.۳ راهنمای بازشونده (فقط «هماهنگی»)
+
+زیر فیلد تاریخ پیگیری، یک دکمهٔ راهنما با آیکون `!` قرار دارد که پنل توضیحات را
+باز/بسته میکند. محتوای پنل:
+
+* «این تاریخ چه کاری میکند؟» — تعریف ساده + اینکه تا پایان همان روز معتبر است.
+* «مثال» — سناریوی واقعی با تاریخ نمونه.
+* «نکات مهم» — خالی + تیک ⇒ ۱۴ روز، خالی + بدون تیک ⇒ بدون تعویق،
+ فقط تمدید (نه کوتاهکردن)، حذف/لغو هماهنگی ⇒ بازگشت فوری، سقف ۶ ماه،
+ تفاوت با «تاریخ بازدید پرسنل»، و توقف پیامک یادآوری در مدت تعویق.
+
+رفتار:
+
+| رویداد | نتیجه |
+|---|---|
+| انتخاب نوع = هماهنگی | دکمهٔ راهنما ظاهر میشود (پنل بسته) |
+| کلیک روی دکمه | پنل باز/بسته میشود و `aria-expanded` بهروز میشود |
+| تغییر نوع به غیر هماهنگی | فیلد و راهنما پنهان و پنل بسته میشود |
+| باز شدن مجدد مودال (ایجاد یا ویرایش) | پنل همیشه بسته شروع میشود |
+
+دسترسیپذیری: دکمه `aria-expanded` و `aria-controls="holdHelpPanel"` دارد و
+پنل با `is-hidden` کنترل میشود.
+
+### قواعد اعتبارسنجی (سرور)
+
+* تاریخ قرار نباید **پیش از امروز** باشد ⇒ پیام: «تاریخ قرار نمیتواند پیش از امروز باشد.»
+* حداکثر **۱۸۳ روز** (≈۶ ماه) جلوتر ⇒ پیام: «قرار بعدی حداکثر میتواند ۱۸۳ روز (۶ ماه) جلوتر باشد.»
+* تاریخ «امروز» پذیرفته است و به **۲۳:۵۹:۵۹ همان روز** تبدیل میشود (تا پایان روز معتبر).
+* اگر اعتبارسنجی رد شود، **هیچ اقدامی ذخیره نمیشود**.
+
+### قاعدهٔ «تمدید مجاز، کوتاهکردن ممنوع» (max)
+
+قرار ذخیرهشده همیشه **بزرگترین** تاریخِ ثبتشده است:
+
+```
+visit_hold_until = max(مقدار فعلی، تاریخ جدید)
+```
+
+| وضعیت | نتیجه |
+|---|---|
+| قرار فعلی ۱ آذر، کارشناس جدید ۸ آذر | **تمدید میشود به ۸ آذر** ✅ |
+| قرار فعلی ۸ آذر، کارشناس جدید ۱ آذر | **تغییری نمیکند** (۸ آذر میماند) |
+
+منطق: اگر مشتری گفته «۸ آذر تماس بگیرید»، سیستم نباید ۱ آذر زنگ بزند. در مقابل،
+یک ثبت اشتباه نباید قرارِ طولانیِ قبلی را نابود کند.
+
+**برای عقببردن قرار** (مثلاً مشتری گفت زودتر بیایید): هماهنگی را **حذف** و دوباره
+با تاریخ جدید ثبت کنید.
+
+
+---
+
+## ۵. صفحهٔ انتظار: چهار گروه و فیلترها
+
+هر مشتری معوق **دقیقاً در یکی** از این چهار گروه است:
+
+| گروه | تعریف |
+|---|---|
+| **A** | معوق، بدون هیچ قرار |
+| **B** | معوق، فقط قرار دستی (`manual`) |
+| **C** | معوق، فقط بازدید برنامهریزیشده یا هماهنگی در انتظار |
+| **D** | معوق، هم قرار دستی و هم بازدید برنامهریزیشده |
+
+### فیلترها (قابل بوکمارک و ترکیب با جستوجو)
+
+| URL | نمایش | شامل |
+|---|---|---|
+| `/panel/entezar/` (بدون پارامتر) | **پیشفرض = فقط A** (رفتار قبلی صفحه) | A |
+| `?view=uncoordinated` | بدون قرار | A |
+| `?view=manual` | قرار هماهنگی | B + D |
+| `?view=scheduled` | بازدید برنامهریزیشده | C + D |
+| `?view=coordinated` | همهٔ دارای قرار | B + C + D |
+| `?view=all` | همهٔ معوقها | A + B + C + D |
+| مقدار نامعتبر | برگشت به پیشفرض، **بدون خطا** | A |
+
+### ستونهای جدول
+
+| ستون | توضیح |
+|---|---|
+| وضعیت قرار | برچسب «با قرار» / «بدون قرار» |
+| قرار هماهنگی | تاریخ شمسی قرار دستی (یا —) |
+| بازدید برنامهریزیشده | تاریخ شمسی قرار `Action` (یا —) |
+| آخرین بازدید | بدون تغییر نسبت به قبل |
+
+### شمارندهٔ داشبورد
+
+`CacheTable('9mahbazdid')` = **گروه A** (کارِ فوری = بدون قرار). هر بار باز شدن
+صفحه بهروز میشود.
+
+---
+
+## ۶. تعامل با سایر بخشها
+
+| بخش | اثر |
+|---|---|
+| **آخرین بازدید** (`last_visit_map`) | دستنخورده — فقط `bazdid + finish` |
+| **چرخهٔ ۹ ماهه** (`cycle_anchor_map`) | دستنخورده — `bazdid` و `nasb` |
+| **اسکنر پیامک** (`scan_visit`) | فقط یک منبع اضافه در `next_appointment_map` |
+| **`recalc_last_bazdid`** | دستنخورده |
+| **`last_bazdid_dt`** | بدون تغییر (نمایش/ترتیب) |
+| **ایندکس `act_visit_idx`** | بدون تغییر |
+
+قاعدهٔ طلایی حفظ شده است: **هماهنگی هرگز بازدید حساب نمیشود.**
+
+---
+
+## ۷. سناریوهای تستشده
+
+| # | سناریو | نتیجهٔ انتظار | وضعیت |
+|---|---|---|---|
+| ۱ | هماهنگی با قرار «امروز» | تا پایان روز خارج از فهرست | ✅ |
+| ۲ | هماهنگی finish بدون تاریخ قرار | پیشفرض ۱۴ روز | ✅ |
+| ۳ | حذف اقدام هماهنگی | قرار بیاثر، مشتری برمیگردد | ✅ |
+| ۴ | لغو هماهنگی (`cancell`) | قرار بیاثر | ✅ |
+| ۵ | هماهنگی دوم با تاریخ کوچکتر | قرار قبلی حفظ میشود | ✅ |
+| ۶ | هماهنگی دوم با تاریخ بزرگتر | قرار تمدید میشود | ✅ |
+| ۷ | قرار بیش از ۶ ماه | رد با پیام، هیچ ذخیرهای | ✅ |
+| ۸ | قرار در گذشته | رد با پیام | ✅ |
+| ۹ | بازدید واقعی بعد از قرار | مشتری از فهرست خارج میماند | ✅ |
+| ۱۰ | هماهنگی finish با تاریخ آینده (بدون ستون) | خارج از فهرست | ✅ |
+| ۱۱ | تفکیک A/B/C/D و هر پنج فیلتر | اعداد و فهرست درست | ✅ |
+| ۱۲ | `?view=nonsense` | پیشفرض، بدون خطا | ✅ |
+| ۱۳ | اسکنر پیامک با قرار دستی | پیامک ارسال نمیشود | ✅ |
+| ۱۴ | اجتماع منابع در `next_appointment_map` | زودترین تاریخ | ✅ |
+
+جمع: **۳۷۶ تست موفق** در کل پروژه (۱۵ تست جدید مربوط به این قابلیت).
+
+---
+
+## ۸. استقرار
+
+```bash
+# ۱) بکاپ
+python manage.py sqlite_db_backup # یا کپی فایل دیتابیس
+
+# ۲) مهاجرت (دو ستون nullable — امن و سریع)
+python manage.py migrate
+
+# ۳) بررسی
+python manage.py check
+python manage.py test tests
+```
+
+### رفتار دادهٔ موجود
+
+* همهٔ مشتریان با `visit_hold_until = NULL` شروع میکنند ⇒ **رفتار امروز دقیقاً حفظ میشود**.
+* هماهنگیهای قدیمی (۵ رکورد سال ۲۰۲۲) هیچ ستونی ندارند و فقط از مسیر `Action`
+ ارزیابی میشوند.
+
+---
+
+## ۹. نکات عملیاتی
+
+1. **حذف هماهنگی** بهترین راه برای «لغو قرار» است.
+2. اگر قرار اشتباه ثبت شد، هماهنگی را **حذف** و دوباره ثبت کنید (قرار جدید فقط
+ میتواند تمدید کند، نه آن را کوتاهتر کند).
+
+---
+
+# بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار
+
+این بخش قابلیتهایی را توضیح میدهد که برای «راحت دیدن اطلاعات و تصمیم درست
+گرفتن» اضافه شدهاند. طراحی **mobile first** است: در موبایل هر مشتری یک کارت
+میشود و در دسکتاپ همان جدول قبلی؛ هیچ صفحه/منوی جدیدی اضافه نشده و شکل کلی
+صفحه حفظ شده است.
+
+## ۱. نوار آمار (بالای صفحه)
+
+پنج کارت که هر کدام هم **عدد** را نشان میدهد و هم **لینک فیلتر** است:
+
+| کارت | چه چیزی را نشان میدهد |
+|---|---|
+| کل معوق | همهٔ مشتریان عقبافتاده (با قرار و بدون قرار) |
+| بدون قرار | کسانی که هیچ قراری ندارند — **کار عملیاتی امروز** |
+| با قرار | قرار هماهنگی یا بازدید برنامهریزیشده دارند |
+| قرار هماهنگی | قرار دستی ثبتشده روی مشتری |
+| بازدید برنامهریزیشده | بازدید/هماهنگیِ در جریان از نوع `Action` |
+
+کارت فعال با حاشیهٔ آبی مشخص میشود.
+
+## ۲. تفکیک مدت تأخیر
+
+زیر نوار آمار، تعداد مشتریان بر اساس **مدت عقبافتادگی** نمایش داده میشود:
+
+- کمتر از ۳۰ روز
+- ۳۰ تا ۹۰ روز
+- ۹۰ تا ۱۸۰ روز
+- بیش از ۶ ماه
+
+**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب (پیشفرض ۹ ماه).
+برای مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بیمعنا نشود.
+یعنی «۳ ماه تأخیر» یعنی «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید».
+
+## ۳. مرتبسازی
+
+سه حالت اختیاری (پارامتر `sort` در URL، قابل بوکمارک):
+
+| مقدار | ترتیب | کاربرد |
+|---|---|---|
+| `oldest` (پیشفرض) | قدیمیترین بازدید اول | اولویت کارشناس |
+| `overdue` | بیشترین تأخیر اول | ببینیم چه کسی از همه دیرتر است |
+| `newest` | جدیدترین بازدید اول | بررسی موارد تازهمعوق |
+
+ترتیب بر مبنای **تاریخ مؤثر بازدید از `Action`** است، نه ستون
+`last_bazdid_dt` (که ممکن است کهنه باشد). برای گروهِ بدون بازدید، مبنای
+ترتیب مقطع دوره است.
+
+## ۴. جستوجو (تکمیلشده)
+
+پیشتر فقط جستوجو بر اساس **آدرس** ممکن بود. اکنون:
+
+| فیلد | جستوجو در |
+|---|---|
+| نام و نام خانوادگی | `name_family` |
+| تلفن | `phone1`، `phone2`، `phone3` |
+| آدرس | `address` |
+| کد مشتری | `idCode` |
+
+نما و مرتبسازی هنگام جستوجو **حفظ میشوند** و دکمهٔ «حذف جستجو» برای برگشت
+به فهرست کامل هست.
+
+## ۵. چیدمان ستونها (بازطراحی)
+
+پیشتر ستون «ردیف» یک ستون کامل جدول را برای یک عدد دو رقمی میگرفت و دو ستون
+با `min-width` ثابت (۱۹۰px و ۳۲۰px) تعیین عرض میکردند؛ نتیجه: بیتناسبی
+فاصلهها و اسکرول افقی. تغییرات:
+
+1. **ستون «ردیف» حذف شد.** شمارهٔ ردیف حالا یک **چیپ کوچک کنار نام** است
+ (`.ez-idx`) که هم فضا نمیخورد و هم برای پیدا کردن ردیف کافی است.
+2. **عرض ستونها با `
` درصدی** تعیین میشود و جدول
+ `table-layout: fixed` است. بنابراین:
+ - مجموع عرضها همیشه ۱۰۰٪ است،
+ - هیچ ستونی بیدلیل پهن نمیشود،
+ - اسکرول افقی در حالت جدول رخ نمیدهد.
+3. **مقیاس فاصله یکسان:** همهٔ حاشیهها مضرب ۰٫۲۵rem (۴px) هستند.
+4. **سرستون چسبان (sticky)** هنگام اسکرول عمودی.
+5. **اعداد همعرض** (`tabular-nums`) تا ستون تاریخها لق نزند.
+6. **تلفن با `direction: ltr`** تا ارقام در متن راستبهچپ جابهجا نشوند.
+7. **آدرس حداکثر دو خط** (`line-clamp`) با متن کامل در `title`.
+8. **تأخیر بهشکل چیپ رنگی** (خاکستری/کهربایی/قرمز/تیره) برای اسکن سریع.
+
+| ستون | عرض | محتوا |
+|---|---|---|
+| نام و نام خانوادگی | ۲۳٪ | چیپ شماره + نام + کد مشتری |
+| وضعیت قرار | ۱۳٪ | برچسب رنگی وضعیت |
+| قرار هماهنگی | ۱۰٪ | تاریخ شمسی قرار دستی |
+| بازدید برنامهریزیشده | ۱۱٪ | تاریخ قرار از `Action` |
+| آخرین بازدید | ۱۰٪ | تاریخ مؤثر واقعی |
+| تأخیر | ۹٪ | چیپ تعداد روز |
+| تلفن | ۱۱٪ | شمارهٔ اول (LTR) |
+| آدرس | ۱۳٪ | دو خط + `title` |
+
+**نقطهٔ شکست (breakpoint):** تا **۸۹۹px** حالت «کارت» و از **۹۰۰px** حالت
+«جدول». این دو مرز باید همیشه یکی باشند؛ اگر نباشند، در بازهای از عرض صفحه هر
+دو حالت همزمان اعمال میشوند و چیدمان میشکند. یک تست اختصاصی
+(`test_card_and_table_breakpoints_agree`) همین را قفل کرده است.
+
+## ۶. قابلیتهای موبایل
+
+- هر ردیف یک **کارت** میشود. نام مشتری سرِ کارت است (بدون برچسب) و بقیهٔ
+ مقادیر با برچسب ثابت ۶٫۵rem همتراز میشوند.
+- برچسب «وضعیت» حذف میشود چون خودِ برچسب رنگی گویاست.
+- دکمهٔ **«تماس»** (`tel:`) و **«ثبت اقدام»** بهصورت دو دکمهٔ تمامعرض.
+- شمارههای دوم و سوم در موبایل مخفیاند (برای تماس، شمارهٔ اول کافی است).
+
+## ۷. نمای خالی
+
+اگر در نما/جستوجوی جاری موردی نباشد، پیام «موردی در این نما پیدا نشد»
+نمایش داده میشود (بهجای جدولِ خالی و گمراهکننده).
+
+## ۸. کارایی
+
+| سنجه | مقدار |
+|---|---|
+| تعداد کوئری در بارگذاری صفحه | **۱۹** |
+| رندر کامل صفحه روی دادهٔ واقعی (۳٬۲۴۳ معوق) | ~۱ ثانیه |
+
+نکتهٔ پیادهسازی: دورهٔ قالب **یک بار** خوانده میشود و به حلقهٔ محاسبهٔ تأخیر
+پاس داده میشود (`months=period_months`). اگر این پارامتر پاس نشود، برای هر
+مشتری یک کوئری اضافه زده میشود (باگ N+1 که در تستها کشف و رفع شد).
+
+## ۹. آنچه تغییر نکرد
+
+- منطق «چه کسی عقبافتاده است» (همان `last_visit_map` + `due_cut`).
+- قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال).
+- اسکنر پیامک، `recalc_last_bazdid` و چرخهٔ ۹ ماهه.
+- لینکهای قدیمی `?view=...` و رفتار پیشفرض (نمای «بدون قرار»).
+
+## ۱۰. پارامترهای URL
+
+```
+/panel/entezar/ نمای پیشفرض (بدون قرار، قدیمیترین)
+?view=all همهٔ معوقها
+?view=coordinated با قرار
+?view=manual فقط قرار هماهنگی
+?view=scheduled فقط بازدید برنامهریزیشده
+?sort=overdue بیشترین تأخیر اول
+?sort=newest جدیدترین بازدید اول
+?searchVal=رضایی&searchOn=name جستوجوی نام
+?searchVal=0912&searchOn=phone جستوجوی تلفن
+?view=all&sort=overdue&page=3 ترکیبپذیر
+```
+
+مقدار نامعتبر `view` یا `sort` بیصدا به پیشفرض برمیگردد (هیچ خطای ۴۰۰/۵۰۰).
+
+3. پس از تاریخ قرار، مشتری **خودکار** در گروه A ظاهر میشود؛ نیازی به کار دستی نیست.
+4. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است.
+
+
+
diff --git a/tests/test_customer_name_fix.py b/tests/test_customer_name_fix.py
index c000464..6c2affa 100644
--- a/tests/test_customer_name_fix.py
+++ b/tests/test_customer_name_fix.py
@@ -305,6 +305,8 @@ class ApplyTests(CommandTestMixin, TestCase):
"id", "name_family", "address", "notes", "phone1", "phone2",
"phone3", "idCode", "installDate", "create_date", "last_bazdid_dt",
"laghv_bazdid", "is_active", "sms_primary_phones",
+ # «قرار بعدی» (تعویق بازدید از فرم هماهنگی) + زمان ثبت برای حسابرسی.
+ "visit_hold_until", "visit_hold_set_at",
})
self.assertNotIn("gender", field_names)
diff --git a/tests/test_visit_policy.py b/tests/test_visit_policy.py
index ad7ec07..5a9ff96 100644
--- a/tests/test_visit_policy.py
+++ b/tests/test_visit_policy.py
@@ -9,20 +9,24 @@
* بازدید انجامشده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد میشود.
"""
import datetime
+import re
import io
import os
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ValidationError
+from django.contrib.auth.models import Permission
from django.core.management import CommandError, call_command
from django.db import connection
from django.test import TestCase
+from django.urls import reverse
from django.utils import timezone
-from admin_panel.models import Action, Customer
+from admin_panel.models import Action, Customer, PhoneNumber
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
- last_visit_map, refresh_last_bazdid,
+ last_visit_map, normalize_hold_until,
+ refresh_last_bazdid, set_visit_hold,
validate_action_payload,
visit_cycle_index, visit_due_date)
@@ -761,56 +765,574 @@ class NextAppointmentEquivalenceTests(TestCase):
self.assertNotIn(customer_c.pk, result)
+class VisitHoldTests(TestCase):
+ """«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسکهای شناساییشده."""
+
+ def setUp(self):
+ from tests.factories import make_user
+
+ self.user = make_user('holdExpert', superuser=True)
+ self.client.force_login(self.user)
+
+ # ---------- helpers ----------
+ def _customer(self, name):
+ customer = make_customer(name)
+ Customer.objects.filter(pk=customer.pk).update(
+ installDate=timezone.localdate() - timedelta(days=400)
+ )
+ customer.refresh_from_db()
+ return customer
+
+ def _entezar(self, view=''):
+ url = '/panel/entezar/' + (f'?view={view}' if view else '')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ return response
+
+ def _ids(self, view=''):
+ return {c.id for c in self._entezar(view).context['customers']}
+
+ def _post_action(self, customer, **payload):
+ base = {'desc': 'تست', 'userID': self.user.id}
+ base.update(payload)
+ for key in [k for k, v in list(base.items()) if v is None]:
+ base.pop(key)
+ return self.client.post(
+ reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base,
+ )
+
+ def _ms(self, moment):
+ return str(int(moment.timestamp() * 1000))
+
+ # ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
+ def test_hold_until_today_hides_customer_until_end_of_day(self):
+ customer = self._customer('قرار امروز')
+ self._post_action(
+ customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
+ is_done='on', visitHoldUntil=self._ms(timezone.now()),
+ )
+ customer.refresh_from_db()
+ self.assertIsNotNone(customer.visit_hold_until)
+ local = customer.visit_hold_until.astimezone(timezone.get_current_timezone())
+ self.assertEqual((local.hour, local.minute), (23, 59))
+ self.assertNotIn(customer.pk, self._ids())
+
+ # ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیشفرض ۱۴ روز ----------
+ def test_finished_coordination_without_hold_defaults_to_two_weeks(self):
+ customer = self._customer('هماهنگی بدون تاریخ قرار')
+ self._post_action(
+ customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
+ is_done='on',
+ )
+ customer.refresh_from_db()
+ self.assertIsNotNone(customer.visit_hold_until)
+ days = (customer.visit_hold_until.date() - timezone.localdate()).days
+ self.assertEqual(days, 14)
+ self.assertNotIn(customer.pk, self._ids())
+
+ # ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بیاثر ----------
+ def test_hold_becomes_inert_when_coordination_is_deleted(self):
+ customer = self._customer('حذف هماهنگی')
+ self._post_action(
+ customer, actionType='hamahangi',
+ dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)),
+ is_done='on',
+ visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)),
+ )
+ self.assertNotIn(customer.pk, self._ids())
+ Action.objects.filter(customer=customer).delete()
+ customer.refresh_from_db()
+ self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است…
+ self.assertIn(customer.pk, self._ids()) # …ولی بیاثر شده است
+
+ def test_hold_becomes_inert_when_coordination_is_cancelled(self):
+ customer = self._customer('لغو هماهنگی')
+ action = Action.objects.create(
+ customer=customer, actionType='hamahangi', status='finish',
+ actionDateTime=timezone.now() + timedelta(days=10),
+ finishDateTime=timezone.now(),
+ )
+ set_visit_hold(customer, timezone.now() + timedelta(days=10))
+ self.assertNotIn(customer.pk, self._ids())
+ Action.objects.filter(pk=action.pk).update(status='cancell')
+ self.assertIn(customer.pk, self._ids())
+
+ # ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ----------
+ def test_second_coordination_does_not_pull_hold_backwards(self):
+ customer = self._customer('قرار قبلی دورتر')
+ set_visit_hold(customer, timezone.now() + timedelta(days=30))
+ written = set_visit_hold(customer, timezone.now() + timedelta(days=5))
+ self.assertFalse(written)
+ customer.refresh_from_db()
+ self.assertEqual(
+ customer.visit_hold_until.date(),
+ (timezone.now() + timedelta(days=30)).date(),
+ )
+
+ def test_second_coordination_can_extend_hold(self):
+ customer = self._customer('تمدید قرار')
+ set_visit_hold(customer, timezone.now() + timedelta(days=5))
+ written = set_visit_hold(customer, timezone.now() + timedelta(days=20))
+ self.assertTrue(written)
+ customer.refresh_from_db()
+ self.assertEqual(
+ customer.visit_hold_until.date(),
+ (timezone.now() + timedelta(days=20)).date(),
+ )
+
+
+ # ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ----------
+ def test_hold_beyond_six_months_is_rejected(self):
+ customer = self._customer('تعویق بلند')
+ response = self._post_action(
+ customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
+ is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)),
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertFalse(Action.objects.filter(customer=customer).exists())
+ customer.refresh_from_db()
+ self.assertIsNone(customer.visit_hold_until)
+
+ def test_hold_in_the_past_is_rejected(self):
+ with self.assertRaises(ValidationError):
+ normalize_hold_until(timezone.now() - timedelta(days=3))
+
+ def test_hold_none_returns_none(self):
+ self.assertIsNone(normalize_hold_until(None))
+
+ # ---------- بازدید واقعی ⇒ مشتری از فهرست خارج میشود ----------
+ def test_real_visit_clears_hold_and_leaves_list(self):
+ customer = self._customer('بازدید واقعی')
+ Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
+ actionDateTime=timezone.now() + timedelta(days=10),
+ finishDateTime=timezone.now())
+ set_visit_hold(customer, timezone.now() + timedelta(days=10))
+ self.assertNotIn(customer.pk, self._ids())
+
+ self._post_action(
+ customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()),
+ is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد',
+ )
+ customer.refresh_from_db()
+ self.assertIsNotNone(customer.last_bazdid_dt)
+ self.assertNotIn(customer.pk, self._ids('all'))
+
+ # ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار میسازد ----------
+ def test_coordination_without_hold_still_hides_via_action(self):
+ customer = self._customer('هماهنگی بدون ستون')
+ Action.objects.create(
+ customer=customer, actionType='hamahangi', status='finish',
+ actionDateTime=timezone.now() + timedelta(days=6),
+ finishDateTime=timezone.now(),
+ )
+ self.assertNotIn(customer.pk, self._ids())
+
+
+ # ---------- تفکیک چهارگروهی و فیلترها ----------
+ def test_groups_and_filters(self):
+ a = self._customer('الف بدون قرار')
+ b = self._customer('ب قرار دستی')
+ c = self._customer('ج بازدید برنامه')
+ d = self._customer('د هر دو')
+
+ # b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
+ Action.objects.create(customer=b, actionType='hamahangi', status='finish',
+ actionDateTime=timezone.now(), finishDateTime=timezone.now())
+ 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))
+ Action.objects.create(customer=d, actionType='bazdid', status='waite',
+ actionDateTime=timezone.now() + timedelta(days=9))
+
+ counts = self._entezar().context['counts']
+ self.assertEqual(counts['uncoordinated'], 1) # a
+ self.assertEqual(counts['manual'], 1) # b
+ self.assertEqual(counts['scheduled'], 1) # c
+ self.assertEqual(counts['both'], 1) # d
+ self.assertEqual(counts['total'], 4)
+ self.assertEqual(counts['coordinated'], 3)
+
+ self.assertEqual(self._ids(), {a.pk})
+ self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
+ self.assertEqual(self._ids('manual'), {b.pk, d.pk})
+ self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
+ self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
+
+ def test_invalid_view_falls_back_to_default(self):
+ a = self._customer('فقط الف')
+ self.assertEqual(self._ids('nonsense'), {a.pk})
+ self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
+
+ # ---------- اسکنر پیامک هم قرار دستی را میبیند ----------
+ def test_scanner_skips_customer_with_manual_hold(self):
+ from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
+ from admin_panel.sms.scanner import scan_visit
+
+ customer = self._customer('قرار + اسکنر')
+ PhoneNumber.objects.create(customer=customer, phone_number='09120000555')
+ config = SMSProviderConfig.load()
+ config.allow_backlog = True
+ config.backlog_max = 50
+ config.scan_since = timezone.now() - timedelta(days=2000)
+ config.save()
+
+ Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
+ actionDateTime=timezone.now() + timedelta(days=15),
+ finishDateTime=timezone.now())
+ set_visit_hold(customer, timezone.now() + timedelta(days=15))
+
+ scan_visit(timezone.localdate())
+ self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
+
+ # ---------- اجتماع منابع ----------
+ def test_next_appointment_is_union_of_sources(self):
+ from admin_panel.visit_policy import next_appointment_map
+
+ customer = self._customer('اجتماع منابع')
+ now = timezone.now()
+ Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
+ actionDateTime=now + timedelta(days=30),
+ finishDateTime=now)
+ set_visit_hold(customer, now + timedelta(days=20))
+ merged = next_appointment_map(now=now)
+ self.assertIn(customer.pk, merged)
+ # قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام میشود.
+ self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date())
+
+
class VisitDedupCleanupTests(TestCase):
"""پاکسازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
- def _keys(self):
- from admin_panel.models import SMSDedup
- return set(SMSDedup.objects.values_list('key', flat=True))
+class ActionModalTemplateTests(TestCase):
+ """قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`).
- def test_removes_only_absurd_keys_by_default(self):
- from admin_panel.models import SMSDedup
+ این تستها تضمین میکنند بازطراحی UI، نام فیلدها/شناسههایی را که ویو
+ انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` میخواند) و
+ مودال با همان تقویم شمسی موجود کار کند.
+ """
- SMSDedup.objects.bulk_create([
- SMSDedup(key='visit:10:24663'), # عدد بیمعنا (باگ تقسیم قدیمی)
- SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
- SMSDedup(key='visit:12:2026-03'), # قالب جدید
- SMSDedup(key='welcome:5'), # نوع دیگر
- ])
- call_command('cleanup_visit_dedup', stdout=io.StringIO())
- self.assertEqual(
- self._keys(),
- {'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
+ def setUp(self):
+ self.user = make_user('modal_viewer')
+ self.user.user_permissions.add(*Permission.objects.filter(
+ content_type__app_label='admin_panel'))
+ self.client.force_login(self.user)
+ self.customer = make_customer('مشتری مودال')
+ self.html = self.client.get(
+ reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
+ ).content.decode()
+
+ def test_expected_form_fields_exist(self):
+ # اینها همان کلیدهایی هستند که ویو از request.POST میخواند.
+ for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc',
+ 'userID', 'is_done', 'FinishDateTime', 'result'):
+ self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است')
+
+ def test_javascript_hooks_exist(self):
+ # JS داخل قالب و ویوهای دیگر به این شناسهها و کلاسها وابستهاند.
+ for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05',
+ 'ActionBox', 'holdHelpToggle', 'holdHelpPanel'):
+ self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است')
+ for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'):
+ self.assertIn(cls, self.html, f'کلاس {cls} گم شده است')
+
+ def test_persian_datepickers_are_used(self):
+ for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'):
+ self.assertIn(cls, self.html, f'تقویم {cls} گم شده است')
+
+ def test_help_panel_is_collapsed_by_default(self):
+ # پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود.
+ self.assertIn('aria-expanded="false"', self.html)
+ self.assertIn('ax-help-panel is-hidden', self.html)
+ self.assertIn('aria-controls="holdHelpPanel"', self.html)
+
+ def test_help_is_scoped_to_coordination(self):
+ # راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند.
+ for marker in ('ax-help-wrap showWhenCoordination is-hidden',
+ 'ax-field showWhenCoordination is-hidden'):
+ self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است')
+
+ def test_form_still_posts_to_new_action(self):
+ self.assertIn(
+ reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}),
+ self.html,
)
- def test_all_legacy_flag_removes_numeric_keys_too(self):
- from admin_panel.models import SMSDedup
+ def test_ui_classes_are_namespaced(self):
+ # پیشوند ax- جلوی تداخل استایل با سایر صفحات را میگیرد.
+ self.assertIn('ax-card', self.html)
+ self.assertIn('ax-section', self.html)
- SMSDedup.objects.bulk_create([
- SMSDedup(key='visit:11:1'),
- SMSDedup(key='visit:12:2026-03'),
- ])
- call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
- self.assertEqual(self._keys(), {'visit:12:2026-03'})
- def test_dry_run_deletes_nothing(self):
- from admin_panel.models import SMSDedup
+class CoordinationDoneConfirmTests(TestCase):
+ """پیشفرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت.
- SMSDedup.objects.create(key='visit:10:24663')
- call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
- self.assertEqual(self._keys(), {'visit:10:24663'})
+ قواعد قفلشده:
+ * فقط «هماهنگی» تیکِ خودکار میگیرد؛ سایر انواع اقدام دستنخوردهاند.
+ * پیش از ثبتِ هماهنگیِ انجامشده، تأیید گرفته میشود.
+ * در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست.
+ """
- def test_customer_and_action_rows_untouched(self):
- from admin_panel.models import SMSDedup
+ def setUp(self):
+ self.user = make_user('confirm_viewer')
+ self.user.user_permissions.add(*Permission.objects.filter(
+ content_type__app_label='admin_panel'))
+ self.client.force_login(self.user)
+ self.customer = make_customer('مشتری تأیید هماهنگی')
+ self.html = self.client.get(
+ reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
+ ).content.decode()
- customer = make_customer('مشتری پاکسازی کلید')
- action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
- before_action = action_snapshot(action)
- before_customer = Customer.objects.values().get(pk=customer.pk)
- SMSDedup.objects.create(key='visit:1:24663')
+ def test_warning_panel_starts_hidden(self):
+ self.assertIn('id="holdDoneWarning"', self.html)
+ block = self.html.split('id="holdDoneWarning"')[0].rsplit('', self.html)]
+ self.assertEqual(len(widths), 8, 'تعداد ستونها باید ۸ باشد')
+ self.assertAlmostEqual(sum(widths), 100.0, places=1)
+
+ def test_row_number_is_not_a_column(self):
+ # شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه.
+ self.assertNotIn('
ردیف | ', self.html)
+ self.assertIn('ez-idx', self.html)
+
+ def test_header_cells_match_body_cells(self):
+ thead = self.html.split('')[0]
+ first_row = self.html.split('')[1].split('')[0]
+ self.assertEqual(thead.count(''), first_row.count(' | ]*min-width', self.html), [])
+
+ def test_card_and_table_breakpoints_agree(self):
+ # اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدودهای
+ # از عرض صفحه هر دو حالت را همزمان میگیرد و چیدمان میشکند.
+ cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html)
+ tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html)
+ if w != '1024'] # 1024 مخصوص نوار آمار است
+ self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد')
+ self.assertIn(str(int(cards[0]) + 1), tables)
+
+ def test_phone_renders_left_to_right(self):
+ self.assertIn('ez-phone', self.html)
+ # شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود.
+ self.assertIn('ez-nowrap', self.html)
+
+
+class EntezarPageControlsTests(TestCase):
+ """کنترلهای صفحهٔ *انتظار*: فیلتر نما، مرتبسازی، جستوجو و کارتهای آمار.
+
+ این تستها رفتار کاربری را قفل میکنند: هر عدد کارت باید با تعداد همان
+ نما بخواند و جستوجو/مرتبسازی نباید ردیفها را از دست بدهد.
+ """
+
+ URL = '/panel/entezar/'
+
+ def setUp(self):
+ self.user = make_user('entezar_viewer')
+ self.user.user_permissions.add(*Permission.objects.filter(
+ content_type__app_label='admin_panel'))
+ self.client.force_login(self.user)
+
+ def _get(self, **params):
+ return self.client.get(self.URL, params)
+
+ def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None):
+ customer = make_customer(name)
+ Customer.objects.filter(pk=customer.pk).update(
+ installDate=(timezone.now() - timedelta(days=install_days_ago)).date())
+ if visit_days_ago is not None:
+ make_visit(customer, action_dt=at(-visit_days_ago),
+ finish_dt=at(-visit_days_ago))
+ return Customer.objects.get(pk=customer.pk)
+
+ def test_default_view_is_uncoordinated(self):
+ response = self._get()
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context['view'], 'uncoordinated')
+ self.assertEqual(response.context['sort'], 'oldest')
+
+ def test_invalid_view_and_sort_fall_back(self):
+ response = self._get(view='nonsense', sort='bogus')
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context['view'], 'uncoordinated')
+ self.assertEqual(response.context['sort'], 'oldest')
+
+ def test_counts_partition_total(self):
+ self._overdue_customer('معوق ساده', visit_days_ago=400)
+ counts = self._get().context['counts']
+ self.assertEqual(counts['total'],
+ counts['uncoordinated'] + counts['coordinated'])
+ self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
+
+ def test_coordinated_customer_hidden_in_default_view(self):
+ customer = self._overdue_customer('با قرار', visit_days_ago=400)
+ # قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
+ make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
+ set_visit_hold(customer, at(+10))
+
+ default_ids = [obj.id for obj in self._get().context['customers']]
+ self.assertNotIn(customer.id, default_ids)
+
+ all_ids = [obj.id for obj in self._get(view='all').context['customers']]
+ self.assertIn(customer.id, all_ids)
+
+ manual_ids = [obj.id for obj in self._get(view='manual').context['customers']]
+ self.assertIn(customer.id, manual_ids)
+
+ def test_cancelled_coordination_returns_customer_to_list(self):
+ customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400)
+ coordination = make_visit(customer, action_dt=at(-1),
+ action_type='hamahangi', status='waite')
+ set_visit_hold(customer, at(+10))
+ self.assertNotIn(customer.id,
+ [obj.id for obj in self._get().context['customers']])
+
+ coordination.status = 'cancell'
+ coordination.save(update_fields=['status'])
+ self.assertIn(customer.id,
+ [obj.id for obj in self._get().context['customers']])
+
+ def test_search_by_name_and_phone(self):
+ customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
+ PhoneNumber.objects.create(customer=customer, title='تلفن',
+ phone_number='09121234567')
+ Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
+
+ for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
+ response = self._get(searchVal=value, searchOn=field)
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(customer.id,
+ [obj.id for obj in response.context['customers']])
+
+ def test_sort_overdue_puts_biggest_delay_first(self):
+ oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
+ newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
+
+ ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']]
+ self.assertLess(ids.index(oldest.id), ids.index(newer.id))
+
+ ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']]
+ self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))
|