- {% for item in sms_toggles %}
-
- {% endfor %}
-
-
-
-
ارسال پیامک سفارشی
-
-
-
-
-{% endif %}
diff --git a/admin_panel/templates/admin_panel/customerNew2.html b/admin_panel/templates/admin_panel/customerNew2.html
index 05273de..af264ab 100644
--- a/admin_panel/templates/admin_panel/customerNew2.html
+++ b/admin_panel/templates/admin_panel/customerNew2.html
@@ -180,9 +180,12 @@
-
- {% include "./_customer_sms.html" %}
+{% comment %}
+ باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
+ (/panel/customer//sms/) منتقل شده تا فرم فاکتور تمامعرض بماند و سوابق و
+ تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
+{% endcomment %}
{% endblock %}
diff --git a/admin_panel/templates/admin_panel/customerSmsHistory.html b/admin_panel/templates/admin_panel/customerSmsHistory.html
index a7afa4e..8bad635 100644
--- a/admin_panel/templates/admin_panel/customerSmsHistory.html
+++ b/admin_panel/templates/admin_panel/customerSmsHistory.html
@@ -4,30 +4,223 @@
{% block BodyCustomer1 %}
+{% comment %}
+ صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
+ قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمینشیند؛ فقط آمار + دکمهها بالاست و
+ تنظیمات/ارسال سفارشی/شمارههای اصلی داخل مودال باز میشوند.
+ همهٔ کلاسها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
+ صفحات دیگر نشت نکنند.
+{% endcomment %}
+
+
-
سوابق پیامک مشتری
-
- همهٔ پیامکهای ارسالشده برای شمارههای این مشتری — جمعاً
- {{ total_count }} پیامک.
-
+
+
+{% endif %}
+{% endblock %}
+
diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py
index d734b42..3b7520b 100644
--- a/admin_panel/views/actions.py
+++ b/admin_panel/views/actions.py
@@ -11,9 +11,10 @@ from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
-from ..sms.triggers import notify_visit_scheduled
+from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
- ActionDeleteError, as_aware, clear_visit_hold,
+ ActionDeleteError, as_aware,
+ cancel_pending_sms_for_action, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
@@ -159,6 +160,45 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False
return result
+# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
+# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمیزد).
+_ACTION_EVENT_SMS = {
+ Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
+ Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
+}
+
+
+def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
+ """همراستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
+
+ * نوع جدید **رویدادی** است (بازدید/نصب):
+ - اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
+ میشود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
+ - اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
+ نمیشود، پس ویرایشهای پیاپی اسپم تولید نمیکنند.
+ * نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
+ لغو میشود (کاربر عملاً بازدید/نصب را پس گرفته است).
+
+ خروجی: تعداد پیامکهای ساختهشده.
+ """
+ current = _ACTION_EVENT_SMS.get(action.actionType)
+ previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
+
+ # نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
+ # وگرنه هر دو پیامک در صف میمانند.
+ if previous is not None and previous_type != action.actionType:
+ cancel_pending_sms_for_action(
+ customer, action_id=action.pk, sms_type=previous[1],
+ )
+
+ if current is None:
+ # نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
+ return 0
+ if previous_type == action.actionType:
+ return 0 # ویرایش عادی همان نوع ⇒ پیامک تازهای لازم نیست
+ return current[0](customer, action, created_by=user)
+
+
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیامهای کاربر."""
if value is None:
@@ -232,9 +272,8 @@ def customerNewAction(request ,c_id):
)
_report_hold_result(request, hold_result)
- # پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
- if newAction.actionType == Action.ActionType.BAZDID:
- notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
+ # پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
+ _sync_action_event_sms(customer_obj, newAction, user=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@@ -270,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
was_done = newAction.status == Action.Status.FINISH
+ # نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
+ # و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
+ previous_type = newAction.actionType
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
@@ -297,6 +339,15 @@ def customerEditAction(request ,c_id, action_id):
)
_report_hold_result(request, hold_result)
+ # پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
+ # فقط *گذار* نوع اقدام بررسی میشود، نه هر ویرایش (جزئیات در
+ # ``_sync_action_event_sms``):
+ # * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده میشود.
+ # * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو میشود.
+ _sync_action_event_sms(
+ customer_obj, newAction, previous_type=previous_type, user=request.user,
+ )
+
#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 981bb1c..9a80ebb 100644
--- a/admin_panel/views/customers.py
+++ b/admin_panel/views/customers.py
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
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 (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
- sms_toggles = customer_toggle_map(customer)
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
- 'sms_toggles': sms_toggles,
}
return render(request, 'admin_panel/customerNew2.html', data)
@@ -494,8 +491,8 @@ def entezar(request):
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
# این نما **مستقل** از «عقبافتاده» است: مشتری سالمی که با او قرار تماس
- # گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
- # ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید نمیآید).
+ # گذاشته شده هم باید دیده شود. قاعدهٔ حذفِ «بازدید/نصب» در خود
+ # ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید یا نصب نمیآید).
today_followups = followups_on_date(today_date, now=now)
# پیگیریهای عقبافتاده: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
diff --git a/admin_panel/views/sms.py b/admin_panel/views/sms.py
index 75e0915..557404b 100644
--- a/admin_panel/views/sms.py
+++ b/admin_panel/views/sms.py
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
+from ..sms.rules import customer_toggle_map
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
@@ -58,6 +59,16 @@ def _notice(url, code, **extra):
def _customer_url(c_id):
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
+
+def _customer_sms_url(c_id):
+ """صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
+
+ تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
+ (تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همینجا بماند، نه به
+ صفحهٔ اصلی مشتری پرتاب شود.
+ """
+ return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
+
# متغیرهای قابل استفاده در متن قالبها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
@@ -79,6 +90,7 @@ SMS_TYPE_UI = {
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
+ SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
}
@@ -313,8 +325,12 @@ def sms_customer_toggle(request, c_id):
)
setting.is_enabled = enabled
setting.save()
- return _notice(_customer_url(c_id), "customer_sms_saved")
- return redirect("admin_panel:customer", c_id=c_id)
+ # ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز میشود تا کاربر
+ # چند نوع را پشتسرهم روشن/خاموش کند و هر بار مودال را باز نکند.
+ return _notice(
+ _customer_sms_url(c_id), "customer_sms_saved", open="settings",
+ )
+ return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -336,13 +352,13 @@ def sms_primary_toggle(request, c_id):
"",
)
if not normalized:
- return _notice(_customer_url(c_id), "primary_not_found")
+ return _notice(_customer_sms_url(c_id), "primary_not_found")
current = [n for n in stored_primary_normalized(customer) if n != normalized]
if enabled:
current.append(normalized)
save_primary_normalized(customer, current)
- return _notice(_customer_url(c_id), "primary_updated")
- return redirect("admin_panel:customer", c_id=c_id)
+ return _notice(_customer_sms_url(c_id), "primary_updated")
+ return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -358,15 +374,16 @@ def sms_send_custom(request, c_id):
text = (request.POST.get("text", "") or "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
- return _notice(_customer_url(c_id), "custom_empty")
+ # خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
+ return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
created = enqueue_sms(
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
- return _notice(_customer_url(c_id), code)
- return redirect("admin_panel:customer", c_id=c_id)
+ return _notice(_customer_sms_url(c_id), code)
+ return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -428,11 +445,17 @@ def sms_queue(request):
@login_required(login_url='/panel/p_login/')
@_manage_required
def customer_sms_history(request, c_id):
- """سوابق پیامکهای یک مشتری روی صفحهٔ خود مشتری.
+ """سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
- همهٔ پیامکهایی که برای شمارههای این مشتری در صف قرار گرفته/ارسال شدهاند
- (هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده میشوند و
- فهرست صفحهبندی میشود. با ``?status=`` و ``?delivery=`` میتوان فیلتر کرد.
+ ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
+
+ * لایهٔ اول (همیشه دیده میشود): آمار کوتاه، فیلترها و فهرست سوابق.
+ * لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شمارههای اصلی و ارسال سفارشی،
+ همه داخل مودالهای Bulma.
+
+ تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
+ (``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
+ دو تمامعرض بمانند.
"""
customer = get_object_or_404(Customer, id=c_id)
status = request.GET.get("status", "")
@@ -446,6 +469,34 @@ def customer_sms_history(request, c_id):
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
+
+ # --- آمار کارتهای بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
+ all_items = SMSQueue.objects.filter(customer=customer)
+ status_counts = dict(
+ all_items.order_by().values_list("status").annotate(n=Count("id"))
+ )
+ total_count = all_items.count()
+
+ # --- تنظیمات: نوعهای پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
+ # ``auto_send`` تنظیمی سراسری است (صفحهٔ قالبها)، ولی دانستنش اینجا کمک
+ # میکند کاربر بداند پیامکِ این نوع بیدرنگ میرود یا منتظر تأیید میماند.
+ auto_send = dict(
+ SMSTemplate.objects.values_list("sms_type", "auto_send")
+ )
+ type_labels = dict(SMSType.choices)
+ sms_toggles = [
+ {
+ "code": item["code"],
+ "label": item["label"],
+ "enabled": item["enabled"],
+ "auto_send": bool(auto_send.get(item["code"])),
+ "icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
+ "when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
+ }
+ for item in customer_toggle_map(customer)
+ ]
+ enabled_count = sum(1 for item in sms_toggles if item["enabled"])
+
data = {
"customer": customer,
"items": page,
@@ -453,8 +504,23 @@ def customer_sms_history(request, c_id):
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
- "total_count": SMSQueue.objects.filter(customer=customer).count(),
+ "total_count": total_count,
"filtered_count": paginator.count,
+ # تنظیمات (مودال)
+ "sms_toggles": sms_toggles,
+ "sms_enabled_count": enabled_count,
+ "sms_type_total": len(type_labels),
+ "phone_entries": customer_phone_entries(customer),
+ # آمار (نوار بالای صفحه)
+ "status_counts": status_counts,
+ "stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
+ # «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
+ "stat_pending": (
+ status_counts.get(SMSQueue.Status.PENDING, 0)
+ + status_counts.get(SMSQueue.Status.APPROVED, 0)
+ + status_counts.get(SMSQueue.Status.PROCESSING, 0)
+ ),
+ "stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
}
return render(request, "admin_panel/customerSmsHistory.html", data)
diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py
index e825861..1a3c6f2 100644
--- a/admin_panel/visit_policy.py
+++ b/admin_panel/visit_policy.py
@@ -437,38 +437,56 @@ def _valid_manual_holds(holds):
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
- coordination_latest = dict(
- Action.objects.filter(
- customer_id__in=customer_ids,
- actionType=Action.ActionType.HAMAHANGI,
- status__in=(Action.Status.WAITE, Action.Status.FINISH),
+ # «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده میشود، نه فقط createdDT.
+ # چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
+ # شوند مقدار یکسان میگیرند؛ با مقایسهٔ صرفاً `>` آنگاه «بازدید بعد از
+ # هماهنگی» تشخیص داده نمیشد و قرارِ مرده معتبر میماند. id یکتای افزایشی
+ # همان ترتیب ثبت را میدهد، پس بهعنوان مرزِ تساوی استفاده میشود.
+ coordination_latest = {
+ cid: (last, last_id)
+ for cid, last, last_id in (
+ Action.objects.filter(
+ customer_id__in=customer_ids,
+ actionType=Action.ActionType.HAMAHANGI,
+ status__in=(Action.Status.WAITE, Action.Status.FINISH),
+ )
+ .values("customer_id")
+ .annotate(last=Max("createdDT"), last_id=Max("id"))
+ .values_list("customer_id", "last", "last_id")
)
- .values("customer_id")
- .annotate(last=Max("createdDT"))
- .values_list("customer_id", "last")
- )
+ }
# آخرین فعالیتِ **جایگزینکنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
- other_latest = dict(
- Action.objects.filter(
- customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
+ other_latest = {
+ cid: (last, last_id)
+ for cid, last, last_id in (
+ Action.objects.filter(
+ customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
+ )
+ .values("customer_id")
+ .annotate(last=Max("createdDT"), last_id=Max("id"))
+ .values_list("customer_id", "last", "last_id")
)
- .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):
- # پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
+ coordination = coordination_latest.get(customer_id)
+ if coordination is None:
continue
+ coordinated_at, coordinated_id = coordination
+ activity = other_latest.get(customer_id)
+ if activity is not None:
+ activity_at, activity_id = activity
+ newer = as_aware(activity_at) > as_aware(coordinated_at) or (
+ as_aware(activity_at) == as_aware(coordinated_at)
+ and activity_id > coordinated_id
+ )
+ if newer:
+ # پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
+ continue
valid[customer_id] = until
return valid
@@ -509,19 +527,25 @@ def _local_day_bounds(target_date):
)
-def _pending_visit_customer_ids(*, since):
- """مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
+def _pending_appointment_ids(*, since):
+ """مشتریانی که **قرار بازدید/نصب** دارند و نباید در فهرست پیگیری بیایند.
- قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
- بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
- (آغاز روز مورد نظر) عقبتر نباشد، از فهرست پیگیری **حذف** میشود.
+ قاعدهٔ محصولی: «اگر برای مشتری بازدید یا نصب گذاشته شده، دیگر لازم نیست
+ تماس بگیریم». پس هر مشتری با اقدام ``bazdid``/``nasb`` و وضعیت ``waite`` که
+ تاریخش از ``since`` (آغاز روز مورد نظر) عقبتر نباشد، از فهرست پیگیری
+ **حذف** میشود.
+
+ چرا «نصب» هم؟ همان دو نوع، چرخهٔ بازدید را از نو شروع میکنند
+ (``_CYCLE_RESET_TYPES``) و قرار دستی را باطل میکنند؛ پس فهرست کار روزانه
+ هم باید همین قاعده را داشته باشد. قبلاً فقط ``bazdid`` دیده میشد و
+ «نصبِ برنامهریزیشده» ردیف را باز میگذاشت.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
- actionType=Action.ActionType.BAZDID,
+ actionType__in=_CYCLE_RESET_TYPES,
status=Action.Status.WAITE,
)
.filter(
@@ -547,8 +571,8 @@ def followups_on_date(target_date, *, now=None):
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
- مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
- **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
+ مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
+ بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
"""
day_start, day_end = _local_day_bounds(target_date)
@@ -593,28 +617,33 @@ def followups_on_date(target_date, *, now=None):
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
- # ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
+ # ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
if result:
- for customer_id in _pending_visit_customer_ids(since=day_start):
+ for customer_id in _pending_appointment_ids(since=day_start):
result.pop(customer_id, None)
- # ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
- # قاعدهٔ کسبوکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
- # بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
+ # ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
+ # قاعدهٔ کسبوکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
+ # فقط بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
# «نیازمند پیگیری امروز» میماند (۹۲٪ ردیفهای دادهٔ واقعی).
+ # لنگر از :func:`cycle_anchor_map` میآید — یعنی همان دو نوعی که چرخه را از نو
+ # شروع میکنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
+ # قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمیکند و «نصب» هم ردیف
+ # را میبندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
+ # (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
# فقط برای امروز/آینده اعمال میشود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
# همان روز را با دادههای بعدی بازنویسی نکند.
if result:
now = _now(now)
current_tz = timezone.get_current_timezone()
if target_date >= now.astimezone(current_tz).date():
- visits = last_visit_map(now=now, customer_ids=list(result))
- for customer_id, visited_at in visits.items():
+ anchors = cycle_anchor_map(now=now, customer_ids=list(result))
+ for customer_id, done_at in anchors.items():
bucket = result.get(customer_id)
- if not bucket or visited_at is None:
+ if not bucket or done_at is None:
continue
due_day = min(bucket.values()).astimezone(current_tz).date()
- if visited_at.astimezone(current_tz).date() >= due_day:
+ if done_at.astimezone(current_tz).date() >= due_day:
result.pop(customer_id, None)
return result
@@ -631,10 +660,10 @@ def overdue_followups(*, now=None, target_date=None):
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
(موعدش تازه است).
- * اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج میشود — همان قاعدهٔ
- :func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
- * اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج میشود
- (پیگیری با بازدید بسته شده است).
+ * اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج میشود — همان
+ قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
+ * اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
+ میشود (پیگیری با بازدید/نصب بسته شده است).
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
خارج میشود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
@@ -669,10 +698,10 @@ def overdue_followups(*, now=None, target_date=None):
if not overdue:
return {}
- # حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
- # «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدیدی که بعد از آن گذاشته شده
- # تکلیف مشتری را روشن کرده است.
- pending_visits = _pending_visit_customer_ids(
+ # حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
+ # «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدید/نصبی که بعد از آن گذاشته
+ # شده تکلیف مشتری را روشن کرده است.
+ pending_visits = _pending_appointment_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
@@ -680,14 +709,16 @@ def overdue_followups(*, now=None, target_date=None):
current_tz = timezone.get_current_timezone()
- # بازدیدِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
+ # بازدید/نصبِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# قبلاً فقط بازدیدِ «در انتظار» دیده میشد و همین، فهرست «از روزهای قبل»
# را پر از مشتریانی میکرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
- visits = last_visit_map(now=now, customer_ids=list(overdue))
- for customer_id, visited_at in visits.items():
- if visited_at is None:
+ # لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
+ # بازدید پیگیری را ببندد.
+ anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
+ for customer_id, done_at in anchors.items():
+ if done_at is None:
continue
- if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
+ if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
overdue.pop(customer_id, None)
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
@@ -1008,14 +1039,24 @@ class ActionDeleteError(Exception):
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
-def cancel_pending_sms_for_action(customer, *, now=None):
- """لغو پیامکهای **ارسالنشدهٔ** «بازدید ثبت شد» همان مشتری.
+def cancel_pending_sms_for_action(customer, *, action_id=None, sms_type=None, now=None):
+ """لغو پیامکهای **ارسالنشدهٔ** رویدادیِ همان مشتری («بازدید/نصب ثبت شد»).
چرا با ``action`` پیدا نمیکنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
دیگر وجود ندارد، پیامکهای معلق همان مشتری با نوع ``visit_created`` لغو
میشوند. پیامکهای **ارسالشده** دست نمیخورند (سابقه است).
+ ``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیشفرض ``visit_created``
+ (رفتار قبلی، که حذف اقدام از آن استفاده میکند). برای «نصب» مقدار
+ ``install_created`` داده میشود.
+
+ ``action_id`` (اختیاری): فقط پیامکهای همان رکورد اقدام را لغو میکند. کلید
+ یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
+ «ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
+ را از بین ببرد. پیشفرض (``None``) همهٔ پیامکهای معلق همان نوع را لغو
+ میکند.
+
خروجی: تعداد رکوردهای لغوشده.
"""
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
@@ -1024,16 +1065,24 @@ def cancel_pending_sms_for_action(customer, *, now=None):
if customer is None or customer.pk is None:
return 0
now = _now(now)
+ sms_type = sms_type or SMSType.VISIT_CREATED
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
- return (
+ query = (
SMSQueue.objects.filter(
customer_id=customer.pk,
- sms_type=SMSType.VISIT_CREATED,
+ sms_type=sms_type,
status__in=pending,
)
.exclude(status=SMSQueue.Status.SENT)
- .update(status=SMSQueue.Status.CANCELLED, updated_at=now)
)
+ if action_id is not None:
+ # ``startswith`` تنها کافی نیست: کلید اقدام ۷۷ پیشوندِ کلید اقدام ۷۷۵ هم
+ # هست. پس مرز «:» صریح گذاشته میشود (شکل ``…:{action_id}:{number}``).
+ base = "%s:%s" % (sms_type, action_id)
+ query = query.filter(
+ models.Q(dedup_key=base) | models.Q(dedup_key__startswith=base + ":")
+ )
+ return query.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
def soft_delete_action(action, *, user=None, now=None):
@@ -1051,7 +1100,8 @@ def soft_delete_action(action, *, user=None, now=None):
۱. ``last_bazdid_dt`` بازمحاسبه میشود (کش کهنه نماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود
(قاعدهٔ «ستون، مرجع نیست»).
- ۳. پیامکهای **ارسالنشده**ی همان مشتری لغو میشوند.
+ ۳. پیامکهای **ارسالنشده**ی رویدادیِ همان مشتری لغو میشوند (``visit_created``
+ و برای اقدام «نصب»، ``install_created``).
ممنوعیت: اقدامی که نظرسنجی ثبتشده دارد (``nazarDT`` پر است) حذف نمیشود؛
زیرا هیچ آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد.
@@ -1081,6 +1131,13 @@ def soft_delete_action(action, *, user=None, now=None):
if customer is not None:
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
cancelled = cancel_pending_sms_for_action(customer, now=now)
+ # اقدام «نصب» پیامک رویدادیِ خودش را هم دارد (نوع جدا)؛ حذف آن باید
+ # پیامک معلقش را هم لغو کند، وگرنه پیامک «نصب ثبت شد» بیمرجع میماند.
+ if action.actionType == Action.ActionType.NASB:
+ from .models import SMSType
+ cancelled += cancel_pending_sms_for_action(
+ customer, sms_type=SMSType.INSTALL_CREATED, now=now,
+ )
else:
cancelled = 0
diff --git a/docs/entezar_action_fields_report.md b/docs/entezar_action_fields_report.md
index 3026cf3..4f145a6 100644
--- a/docs/entezar_action_fields_report.md
+++ b/docs/entezar_action_fields_report.md
@@ -158,7 +158,8 @@ customerActionInc.html
| نوع | زمان ارسال | تصمیمگیر | دادههای دخیل |
|---|---|---|---|
| `visit` (دورهای) | اسکنر (`scan_visit`) وقتی موعدِ «آخرین بازدید + ۹ ماه» رسیده باشد | `cycle_anchor_map` + `next_appointment_map` | **هر دو فیلد** (فیلد اول بهواسطهٔ لنگر، فیلد دوم بهواسطهٔ قرار) |
-| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
+| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** — هم در «ایجاد» و هم وقتی نوع اقدام در «ویرایش» **به** بازدید تغییر کند | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
+| `install_created` (رویدادی) | بلافاصله پس از ثبت اقدام **نصب** — همان دو مسیر (ایجاد و تغییر نوع در ویرایش) | `notify_install_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
### ۴.۲ ماتریس اثر (نتیجهٔ شبیهسازی، همه با `scan_visit`)
@@ -171,6 +172,7 @@ customerActionInc.html
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
| هماهنگی «انجامشده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
+| ثبت اقدام نصب (هر وضعیتی) | — | ✅ `install_created`، مستقل از `visit_created` |
| ثبت اقدام هماهنگی | — | ❌ |
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
@@ -181,11 +183,13 @@ customerActionInc.html
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
-چون این پیامک در `views/actions.py:170-171` فقط برای `actionType == bazdid` صدا زده
-میشود، و اصلاً از `next_appointment_map` عبور نمیکند. یعنی اگر هماهنگی بگذارید و بعد
-بازدیدی ثبت کنید، پیامکِ «بازدید شما برای تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ
-پیگیری نشود» فعال باشد. (اگر رفتار مطلوب چیز دیگری است، باید در `notify_visit_scheduled`
-یا در شرط فراخوانی آن تغییر کند.)
+چون این پیامک در ویوهای اقدام (`views/actions.py`، تابع `_sync_action_event_sms`) فقط
+برای `actionType == bazdid` صدا زده میشود، و اصلاً از `next_appointment_map` عبور
+نمیکند. یعنی اگر هماهنگی بگذارید و بعد بازدیدی ثبت کنید، پیامکِ «بازدید شما برای
+تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ پیگیری نشود» فعال باشد. همین قاعده برای
+`install_created` (اقدام از نوع نصب) هم برقرار است. (اگر رفتار مطلوب چیز دیگری است،
+باید در `notify_visit_scheduled`/`notify_install_scheduled` یا در شرط فراخوانی آنها
+تغییر کند.)
### ۴.۴ جزئیات مهم `scan_visit`
@@ -525,6 +529,30 @@ for customer_id in manual_holds_map(now=now):
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته میشد (وابسته به تایمزون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را مینوشت (حتی بدون تغییر) | `save()` بیقید در ویو | فقط در صورت تغییر + `update_fields` |
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» میتوانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
+| B9 | کسبوکار | اقدام **«نصب»** ردیف را از فهرستهای کار (`?view=today` و `?view=today&missed=1`) حذف نمیکرد؛ فقط «بازدید» حذف میکرد | `followups_on_date`/`overdue_followups` در دو مرحلهٔ «قرار در انتظار» و «انجامشده» فقط `bazdid` را میدیدند (`_pending_visit_customer_ids` و `last_visit_map`) | آن دو مرحله به `_pending_appointment_ids` (با `_CYCLE_RESET_TYPES` = `bazdid`/`nasb`) و `cycle_anchor_map` منتقل شدند؛ `last_visit_map` دستنخورده ماند |
+| B10 | رویداد | تغییر نوع یک اقدام از «هماهنگی» به «بازدید» در فرم **ویرایش**، پیامک `visit_created` نمیفرستاد | تریگر `notify_visit_scheduled` فقط در `customerNewAction` صدا زده میشد (یک فراخوان در کل کد) | در `customerEditAction` گذار نوع بررسی میشود: غیربازدید → بازدید ⇒ تریگر؛ بازدید → غیربازدید ⇒ `cancel_pending_sms_for_action(action_id=…)`. تکرار با `dedup_key=visit_created:{action.pk}` مهار میشود |
+| F1 | قابلیت | نوع پیامک «ثبت نصب» (اقدام از نوع نصب) وجود نداشت | فرمهای اقدام از نوع نصب هیچ تریگر پیامکی نداشتند | `SMSType.INSTALL_CREATED` + `notify_install_scheduled` + قالب قابل ویرایش در `/panel/sms/templates` + تریگر در هر دو ویوی ایجاد/ویرایش (مهاجرت ۰۰۸۱) |
+
+> **F1 — قیدهای ایمنی مهاجرت ۰۰۸۱:** مهاجرت فقط `choices` را گسترش میدهد
+> (`AlterField` = متادیتا، بدون تغییر داده) و **یک** ردیف تازه با
+> `get_or_create` میسازد. هیچ `update`/`save` روی قالبهای موجود ندارد؛ اگر قالب
+> `install_created` از قبل باشد هم دست نمیخورد. قالب تازه:
+> `is_enabled=True` (در پنل دیده شود) و **`auto_send=False`** ⇒ پیامکها در صف
+> «در انتظار تأیید» میمانند تا مدیر خودش تأیید کند. تستهای نگهبان:
+> `InstallTemplateMigrationTests` و `test_install_template_is_seeded_with_auto_send_off`
+> در `tests/test_sms.py`.
+
+> **B9 — جزئیات (۲۰۲۶-۱۰-۲۷):** «نصب» از قبل در `_SUPERSEDING_TYPES` (ابطال قرار
+> دستی) و `_CYCLE_RESET_TYPES` (ریست چرخهٔ ۹ ماهه در اسکنر پیامک) بود، ولی فهرست
+> کار روزانه آن را نمیدید. حالا:
+>
+> * `nasb + waite` با تاریخ از آغاز روز مورد نظر به بعد ⇒ ردیف پنهان میشود
+> (همان قاعدهٔ `bazdid + waite`؛ «قرار امروز» تا پایان امروز معتبر است).
+> نصبِ در انتظارِ **قدیمیتر** از آن روز، قرار فعال نیست و ردیف را باز نمیگذارد.
+> * `nasb + finish` با تاریخ مؤثر از موعد به بعد ⇒ ردیف بسته میشود (همان قاعدهٔ
+> `bazdid + finish`).
+> * `last_visit_map` (تاریخ آخرین **بازدید**، ستون جدول، `last_bazdid_dt` و پیامک)
+> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md
index b72fa83..0b1b93d 100644
--- a/docs/visit_hold_entezar.md
+++ b/docs/visit_hold_entezar.md
@@ -64,9 +64,16 @@
| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
| لغو هماهنگی (`cancell`) | بیاثر میشود |
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
-| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
+| **ثبت بازدید یا نصب پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
+| ثبت سایر اقدامها (وصول، تماس…) | قرار دستنخورده میماند |
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
+> **تکمیل ۲۰۲۶-۱۰-۲۷:** «نصب» در فهرستهای کار هم مثل «بازدید» عمل میکند:
+> `nasb + waite` با تاریخ از امروز به بعد و `nasb + finish` از موعد به بعد، ردیف را
+> از `?view=today` و `?view=today&missed=1` خارج میکند
+> (`visit_policy._pending_appointment_ids` و `cycle_anchor_map`). این دو نوع همان
+> `_CYCLE_RESET_TYPES` هستند که چرخهٔ ۹ ماهه و قرار دستی را هم بازنشانی میکنند.
+
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
---
diff --git a/tests/test_entezar_action_simulation.py b/tests/test_entezar_action_simulation.py
index 2e1a400..3dcb122 100644
--- a/tests/test_entezar_action_simulation.py
+++ b/tests/test_entezar_action_simulation.py
@@ -368,6 +368,25 @@ class AppointmentLifetimeSimulationTests(SimulationBase):
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertNotIn(self.customer.pk, manual_holds_map())
+ def test_same_microsecond_visit_still_kills_appointment(self):
+ """ثبت بازدید در همان لحظهٔ هماهنگی هم قرار را باطل میکند.
+
+ ``createdDT`` روی SQLite/ویندوز دقت کافی ندارد و دو رکورد پشتسرهم میتوانند
+ مقدار یکسان بگیرند؛ مقایسه با ``id`` بهعنوان مرز تساوی، این حالت را هم
+ درست تشخیص میدهد (قبلاً تست به زمان اجرا وابسته و ناپایدار بود).
+ """
+ from admin_panel.models import Action
+
+ self._hold_customer()
+ visit = make_action(self.customer, 'bazdid', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ # هر دو رکورد همان createdDT را میگیرند (بازسازی شرط مسابقه).
+ stamp = Action.objects.get(
+ customer=self.customer, actionType='hamahangi',
+ ).createdDT
+ Action.objects.filter(pk=visit.pk).update(createdDT=stamp)
+ self.assertNotIn(self.customer.pk, manual_holds_map())
+
class FollowupDaySimulationTests(SimulationBase):
"""فهرست «نیازمند پیگیری امروز»: کارِ باقیمانده، نه آرشیو."""
@@ -397,6 +416,45 @@ class FollowupDaySimulationTests(SimulationBase):
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertIn(self.customer.pk, followups_on_date(yesterday))
+ # --- «نصب» هم مثل بازدید فهرست کار را میبندد ---
+ # قاعدهٔ کسبوکار: نصب و بازدید هر دو چرخه را از نو شروع میکنند
+ # (``_CYCLE_RESET_TYPES``) و قرار دستی را باطل میکنند؛ پس فهرست «نیازمند
+ # پیگیری» هم باید با هر دو بسته شود.
+ def test_install_done_today_clears_today_row(self):
+ """«نصبِ انجامشدهٔ امروز» هم ردیف امروز را میبندد (مثل بازدید)."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
+ make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
+ self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate()))
+ ids, _ = self.stale_ids('today')
+ self.assertNotIn(self.customer.pk, ids)
+
+ def test_scheduled_install_hides_today_row(self):
+ """«نصب» با وضعیت waite و تاریخ **آینده** ⇒ ردیف امروز پنهان میشود."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
+ make_action(self.customer, 'nasb', 'waite', action_dt=at(7))
+ self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate()))
+ ids, _ = self.stale_ids('today')
+ self.assertNotIn(self.customer.pk, ids)
+
+ def test_old_pending_install_does_not_hide(self):
+ """نصبِ «در انتظار» با تاریخ گذشته قرار فعال نیست ⇒ ردیف میماند."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ make_action(self.customer, 'nasb', 'waite', action_dt=at(-30))
+ self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
+
+ def test_install_yesterday_does_not_clear_today_row(self):
+ """نصبِ دیروز، موعدِ امروز را نمیبندد (اگر موعد تازهای گذاشته شده)."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ make_action(self.customer, 'nasb', 'finish',
+ action_dt=at(-1), finish_dt=at(-1))
+ self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
+
class TaskListBoundaryTests(SimulationBase):
"""«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware.
@@ -744,8 +802,9 @@ class MissedViewSimulationTests(SimulationBase):
"""رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ.
قاعدهٔ کسبوکار: این فهرست فقط «پیگیریهای واقعاً باقیمانده» را نشان میدهد.
- هر مشتری که (الف) بازدیدش از موعد به بعد انجام شده، یا (ب) قرار دستیِ معتبر
- دارد، یا (ج) بازدیدِ در انتظار از آن روز دارد، از فهرست خارج میشود.
+ هر مشتری که (الف) بازدید یا نصبش از موعد به بعد انجام شده، یا (ب) قرار
+ دستیِ معتبر دارد، یا (ج) بازدید/نصبِ در انتظار از آن روز دارد، از فهرست
+ خارج میشود.
"""
URL = '/panel/entezar/?view=today&missed=1'
@@ -807,6 +866,25 @@ class MissedViewSimulationTests(SimulationBase):
ids, _ = self.missed()
self.assertNotIn(self.customer.pk, ids)
+ def test_pending_install_removes_from_missed(self):
+ """«نصبِ» در انتظار با تاریخ آینده هم مشتری را از missed خارج میکند."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=at(-3), finish_dt=at(-3))
+ make_action(self.customer, 'nasb', 'waite', action_dt=at(5))
+ ids, _ = self.missed()
+ self.assertNotIn(self.customer.pk, ids)
+
+ def test_finished_install_after_coordination_is_not_listed(self):
+ """هماهنگی دیروز + نصب انجامشدهٔ امروز ⇒ نباید در missed بیاید."""
+ make_action(self.customer, 'hamahangi', 'finish',
+ action_dt=at(-1, -2), finish_dt=at(-1))
+ make_action(self.customer, 'nasb', 'finish',
+ action_dt=NOW, finish_dt=NOW)
+ ids, response = self.missed()
+ self.assertNotIn(self.customer.pk, ids)
+ self.assertNotIn(self.customer.pk, overdue_followups())
+ self.assertEqual(response.context['counts']['followup_overdue'], 0)
+
def test_old_pending_visit_does_not_remove(self):
"""بازدیدِ در انتظارِ قدیمیتر از موعد، قرار فعال نیست ⇒ در missed میماند."""
make_action(self.customer, 'hamahangi', 'finish',
diff --git a/tests/test_sms.py b/tests/test_sms.py
index 55211f4..dd10eb7 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -2177,14 +2177,14 @@ class EventTriggerTests(PanelTestCase):
mark_primary(customer, '09120000000')
return customer
- # ---------- قرارداد: دقیقاً ۷ نوع ----------
+ # ---------- قرارداد: دقیقاً ۸ نوع ----------
- def test_exactly_seven_sms_types(self):
- self.assertEqual(len(SMSType.choices), 7)
+ def test_exactly_eight_sms_types(self):
+ self.assertEqual(len(SMSType.choices), 8)
self.assertEqual(
{code for code, _label in SMSType.choices},
{'welcome', 'survey', 'cheque_registration', 'visit',
- 'visit_created', 'payment_thanks', 'nesiye_due'},
+ 'visit_created', 'install_created', 'payment_thanks', 'nesiye_due'},
)
def test_removed_types_have_no_template(self):
@@ -2198,6 +2198,15 @@ class EventTriggerTests(PanelTestCase):
seeded = set(SMSTemplate.objects.values_list('sms_type', flat=True))
self.assertEqual(seeded, {code for code, _label in SMSType.choices})
+ def test_install_template_is_seeded_with_auto_send_off(self):
+ """قالب «ثبت نصب» فعال است ولی «ارسال خودکار» آن خاموش (نیازمند تأیید)."""
+ template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
+ self.assertTrue(template.is_enabled)
+ self.assertFalse(template.auto_send)
+ self.assertEqual(template.offset_days, 0)
+ self.assertIn('{name}', template.text)
+ self.assertIn('{date}', template.text)
+
# ---------- نظرسنجی + تشکر از پرداخت نقدی روی فاکتور ----------
def _post_factor(self, customer, payments):
@@ -2284,6 +2293,244 @@ class EventTriggerTests(PanelTestCase):
SMSQueue.objects.filter(sms_type=SMSType.VISIT_CREATED).exists(),
)
+ # ---------- تبدیل نوع اقدام در «ویرایش» ----------
+ # باگ گزارششده: تریگر «بازدید ثبت شد» فقط در ویوی ایجاد صدا زده میشد، پس
+ # تبدیل یک اقدام «هماهنگی» به «بازدید» هیچ پیامکی نمیفرستاد.
+
+ def _visit_sms(self, customer):
+ """همهٔ ردیفهای «ثبت بازدید» (هر وضعیتی) — برای شمارش کل."""
+ return SMSQueue.objects.filter(customer=customer,
+ sms_type=SMSType.VISIT_CREATED)
+
+ def _pending_visit_sms(self, customer):
+ """فقط ردیفهایی که worker ممکن است بفرستد (در انتظار/تأییدشده)."""
+ return self._visit_sms(customer).filter(
+ status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
+ )
+
+ def _edit_action(self, customer, action_type, *, is_done=True):
+ """ویرایش اولین اقدام مشتری با نوع جدید (از مسیر واقعی ویو)."""
+ from admin_panel.models import Action
+
+ action = Action.objects.filter(customer=customer).order_by('id').first()
+ url = reverse('admin_panel:customerEditAction',
+ kwargs={'c_id': customer.id, 'action_id': action.id})
+ payload = {
+ 'actionType': action_type,
+ 'dateTimeAnjam': '1700000000000',
+ 'desc': 'ویرایش', 'userID': self.user.id,
+ 'FinishDateTime': '1700000000000',
+ }
+ if is_done:
+ payload['is_done'] = 'on'
+ response = self.client.post(
+ url, payload,
+ HTTP_REFERER=reverse('admin_panel:customerAction',
+ kwargs={'c_id': customer.id}),
+ )
+ action.refresh_from_db()
+ return response, action
+
+ def test_editing_coordination_to_bazdid_enqueues_visit_created(self):
+ """هماهنگی → بازدید در ویرایش ⇒ پیامک «ثبت بازدید» باید ساخته شود."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'hamahangi')
+ self.assertEqual(self._visit_sms(customer).count(), 0)
+
+ response, action = self._edit_action(customer, 'bazdid')
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(action.actionType, 'bazdid')
+ self.assertEqual(self._visit_sms(customer).count(), 1)
+ # کلید یکتاسازی همان رکورد اقدام است (پس ویرایش دوباره تکرار نمیکند).
+ self.assertEqual(self._visit_sms(customer).first().dedup_key,
+ 'visit_created:%d' % action.id)
+
+ def test_editing_to_bazdid_twice_does_not_duplicate_sms(self):
+ """ویرایش دوبارهٔ همان رکورد نباید پیامک دوم بسازد (dedup روی action)."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'hamahangi')
+ self._edit_action(customer, 'bazdid')
+ # برگشت به هماهنگی و تبدیل دوباره به بازدید: پیامک معلق لغو شده، ولی
+ # کلید دفتر dedup خورده است ⇒ پیامک تازه ساخته نمیشود.
+ self._edit_action(customer, 'hamahangi')
+ self._edit_action(customer, 'bazdid')
+ self.assertEqual(self._visit_sms(customer).count(), 1)
+
+ def test_editing_bazdid_to_other_type_cancels_pending_sms(self):
+ """بازدید → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت بازدید» لغو میشود."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'bazdid')
+ self.assertEqual(self._pending_visit_sms(customer).count(), 1)
+
+ self._edit_action(customer, 'hamahangi')
+ # ردیف حذف نمیشود (سابقه میماند) ولی دیگر ارسالشدنی نیست.
+ self.assertEqual(self._pending_visit_sms(customer).count(), 0)
+ self.assertEqual(
+ self._visit_sms(customer).first().status, SMSQueue.Status.CANCELLED,
+ )
+
+ def test_editing_other_fields_of_bazdid_sends_no_new_sms(self):
+ """ویرایش عادی یک اقدام بازدید (بدون تغییر نوع) پیامک تازه نمیفرستد."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'bazdid')
+ self.assertEqual(self._visit_sms(customer).count(), 1)
+ self._edit_action(customer, 'bazdid')
+ self.assertEqual(self._visit_sms(customer).count(), 1)
+
+ def test_editing_bazdid_keeps_other_actions_pending_sms(self):
+ """لغو پیامک معلق، فقط رکورد ویرایششده را هدف میگیرد (نه بقیهٔ بازدیدها)."""
+ from admin_panel.models import Action
+ from admin_panel.sms.triggers import notify_visit_scheduled
+
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'bazdid') # رکورد ۱ + پیامک
+ first = Action.objects.filter(customer=customer).order_by('id').first()
+ second = Action.objects.create(
+ customer=customer, actionType='bazdid', status='finish',
+ actionDateTime=timezone.now(), finishDateTime=timezone.now(),
+ )
+ notify_visit_scheduled(customer, second, created_by=self.user) # پیامک رکورد ۲
+ self.assertEqual(self._pending_visit_sms(customer).count(), 2)
+
+ # رکورد دوم را از بازدید به هماهنگی تغییر میدهیم ⇒ فقط پیامک رکورد ۲ لغو شود.
+ url = reverse('admin_panel:customerEditAction',
+ kwargs={'c_id': customer.id, 'action_id': second.id})
+ self.client.post(url, {
+ 'actionType': 'hamahangi', 'dateTimeAnjam': '1700000000000',
+ 'FinishDateTime': '1700000000000', 'desc': '', 'userID': self.user.id,
+ 'is_done': 'on',
+ }, HTTP_REFERER=reverse('admin_panel:customerAction',
+ kwargs={'c_id': customer.id}))
+
+ keys = set(self._pending_visit_sms(customer).values_list('dedup_key', flat=True))
+ self.assertEqual(keys, {'visit_created:%d' % first.id})
+
+ # ---------- ثبت اقدام از نوع «نصب» ----------
+
+ def _install_sms(self, customer):
+ """همهٔ ردیفهای «ثبت نصب» (هر وضعیتی)."""
+ return SMSQueue.objects.filter(customer=customer,
+ sms_type=SMSType.INSTALL_CREATED)
+
+ def _pending_install_sms(self, customer):
+ return self._install_sms(customer).filter(
+ status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
+ )
+
+ def test_nasb_action_enqueues_install_created(self):
+ """اقدام «نصب» ⇒ پیامک «ثبت نصب» از قالب install_created."""
+ customer = self._customer_with_phone()
+ response = self._post_action(customer, 'nasb')
+ self.assertIn(response.status_code, (200, 302))
+ item = self._install_sms(customer)
+ self.assertEqual(item.count(), 1)
+ self.assertIn('نصب', item.first().message)
+ # auto_send قالب خاموش است ⇒ ردیف «در انتظار تأیید» میماند، نه ارسال فوری.
+ self.assertEqual(item.first().status, SMSQueue.Status.PENDING)
+ # پیامک بازدید ساخته نمیشود (دو نوع مستقل).
+ self.assertEqual(self._pending_visit_sms(customer).count(), 0)
+
+ def test_nasb_action_sms_has_its_own_dedup_key(self):
+ """کلید یکتاسازی با پیشوند نوع خودش است (با visit_created قاطی نمیشود)."""
+ from admin_panel.models import Action
+
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'nasb')
+ action = Action.objects.filter(customer=customer).order_by('id').first()
+ self.assertEqual(self._install_sms(customer).first().dedup_key,
+ 'install_created:%d' % action.id)
+
+ def test_non_nasb_action_has_no_install_created(self):
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'bazdid')
+ self.assertFalse(self._install_sms(customer).exists())
+
+ def test_editing_to_nasb_enqueues_install_created(self):
+ """هماهنگی → نصب در ویرایش ⇒ پیامک «ثبت نصب» ساخته میشود."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'hamahangi')
+ self.assertEqual(self._install_sms(customer).count(), 0)
+
+ response, action = self._edit_action(customer, 'nasb')
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(action.actionType, 'nasb')
+ self.assertEqual(self._pending_install_sms(customer).count(), 1)
+
+ def test_editing_nasb_to_other_type_cancels_pending_install_sms(self):
+ """نصب → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت نصب» لغو میشود."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'nasb')
+ self.assertEqual(self._pending_install_sms(customer).count(), 1)
+
+ self._edit_action(customer, 'hamahangi')
+ self.assertEqual(self._pending_install_sms(customer).count(), 0)
+ self.assertEqual(
+ self._install_sms(customer).first().status, SMSQueue.Status.CANCELLED,
+ )
+
+ def test_editing_bazdid_to_nasb_switches_sms_type(self):
+ """بازدید → نصب ⇒ پیامک بازدید لغو و پیامک نصب ساخته میشود (نه هر دو)."""
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'bazdid')
+ self.assertEqual(self._pending_visit_sms(customer).count(), 1)
+
+ self._edit_action(customer, 'nasb')
+ self.assertEqual(self._pending_visit_sms(customer).count(), 0)
+ self.assertEqual(self._pending_install_sms(customer).count(), 1)
+
+ def test_install_template_can_be_disabled_from_templates_page(self):
+ """تنظیمات «ثبت نصب» از صفحهٔ قالبها: غیرفعالکردن ⇒ پیامکی ساخته نمیشود."""
+ template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
+ response = self.client.post(
+ reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
+ {'text': template.text, 'title': template.title},
+ )
+ self.assertEqual(response.status_code, 302)
+ template.refresh_from_db()
+ self.assertFalse(template.is_enabled)
+
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'nasb')
+ self.assertFalse(self._install_sms(customer).exists())
+
+ def test_install_template_text_is_editable_from_templates_page(self):
+ """ویرایش متن قالب از پنل ⇒ همان متن در پیامک ساختهشده میآید."""
+ template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
+ response = self.client.post(
+ reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
+ {'text': '{name} عزیز، نصب دستگاه انجام شد ({date}).',
+ 'title': 'ثبت نصب', 'is_enabled': 'on'},
+ )
+ self.assertEqual(response.status_code, 302)
+
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'nasb')
+ message = self._install_sms(customer).first().message
+ self.assertIn('نصب دستگاه انجام شد', message)
+ self.assertIn(customer.name_family, message)
+
+ def test_install_sms_disabled_per_customer_is_not_created(self):
+ """opt-out مشتری برای نوع install_created رعایت میشود."""
+ from admin_panel.models import CustomerSMSSetting
+
+ customer = self._customer_with_phone()
+ CustomerSMSSetting.objects.create(
+ customer=customer, sms_type=SMSType.INSTALL_CREATED, is_enabled=False,
+ )
+ self._post_action(customer, 'nasb')
+ self.assertFalse(self._install_sms(customer).exists())
+
+ def test_install_template_can_be_enabled_for_auto_send(self):
+ """اگر مدیر «ارسال خودکار» را روشن کند، ردیف مستقیماً تأییدشده ساخته میشود."""
+ SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).update(
+ auto_send=True,
+ )
+ customer = self._customer_with_phone()
+ self._post_action(customer, 'nasb')
+ self.assertEqual(
+ self._install_sms(customer).first().status, SMSQueue.Status.APPROVED,
+ )
+
# ---------- تشکر از پرداخت (وصول چک، نسیه، سند دستی) ----------
def test_pass_check_enqueues_payment_thanks(self):
@@ -2372,6 +2619,85 @@ class EventTriggerTests(PanelTestCase):
)
+class InstallTemplateMigrationTests(TestCase):
+ """مهاجرت ۰۰۸۱: قالبهای موجود دستنخورده میمانند، «ثبت نصب» بدون ارسال خودکار.
+
+ توابع مهاجرت مستقیم و با همان ``apps`` واقعی صدا زده میشوند (بدون اجرای
+ schema)، تا در تراکنش تست انجام و بازگردانی شود.
+ """
+
+ MODULE = 'admin_panel.migrations.0081_sms_type_install_created'
+ FIELDS = ('sms_type', 'title', 'text', 'is_enabled', 'auto_send',
+ 'offset_days', 'repeat_interval_days', 'repeat_interval_months')
+
+ class _Apps:
+ """جایگزین ``schema_editor.apps``: نگاشت به مدلهای واقعی پروژه."""
+
+ @staticmethod
+ def get_model(app_label, model_name):
+ from django.apps import apps as real_apps
+ return real_apps.get_model(app_label, model_name)
+
+ def _migration(self):
+ from importlib import import_module
+ return import_module(self.MODULE)
+
+ def _snapshot(self):
+ return {row['sms_type']: row
+ for row in SMSTemplate.objects.values(*self.FIELDS)}
+
+ def _seed(self):
+ self._migration().seed_install_template(self._Apps, None)
+
+ def test_seed_leaves_existing_templates_untouched(self):
+ SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
+ # مقادیر غیرپیشفرض؛ اگر مهاجرت هر فیلدی را بازنویسی کند دیده میشود.
+ (SMSTemplate.objects.filter(sms_type=SMSType.VISIT).update(
+ title='عنوان دستی', text='متن دستی {name}', is_enabled=False,
+ auto_send=True, offset_days=3, repeat_interval_months=6,
+ ))
+ before = self._snapshot()
+
+ self._seed()
+
+ after = self._snapshot()
+ for sms_type, row in before.items():
+ self.assertEqual(after[sms_type], row, msg=sms_type)
+ self.assertIn(SMSType.INSTALL_CREATED, after)
+
+ def test_seeded_install_template_has_auto_send_off(self):
+ SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
+ self._seed()
+ template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
+ self.assertTrue(template.is_enabled, msg='باید در پنل قالبها دیده شود')
+ self.assertFalse(template.auto_send, msg='نباید بدون تأیید مدیر ارسال شود')
+ self.assertEqual(template.offset_days, 0)
+ self.assertIsNone(template.repeat_interval_days)
+ self.assertIsNone(template.repeat_interval_months)
+
+ def test_seed_is_idempotent_for_an_existing_install_template(self):
+ """اگر قالب از قبل وجود داشته باشد، متن/تنظیماتش بازنویسی نمیشود."""
+ template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
+ SMSTemplate.objects.filter(pk=template.pk).update(
+ text='متن ویرایششدهٔ مدیر {name}', auto_send=True, offset_days=5,
+ )
+ before = self._snapshot()
+
+ self._seed()
+
+ self.assertEqual(self._snapshot(), before)
+
+ def test_reverse_only_removes_the_new_type(self):
+ before = self._snapshot()
+ self._migration().drop_install_template(self._Apps, None)
+ after = self._snapshot()
+
+ self.assertNotIn(SMSType.INSTALL_CREATED, after)
+ self.assertEqual(set(after), set(before) - {SMSType.INSTALL_CREATED})
+ for sms_type, row in after.items():
+ self.assertEqual(row, before[sms_type], msg=sms_type)
+
+
class CustomerSmsHistoryTests(PanelTestCase):
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحهبندی."""
@@ -2468,6 +2794,92 @@ class CustomerSmsHistoryTests(PanelTestCase):
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('پیامکی برای این مشتری ثبت نشده است', html)
+ # ---------- انتقال تنظیمات پیامک از صفحهٔ فاکتور به این صفحه ----------
+
+ def test_factor_page_no_longer_contains_sms_settings_box(self):
+ """باکس «پیامک» از صفحهٔ ایجاد فاکتور حذف شده (تنظیمات به تب پیامک رفت)."""
+ html = self.client.get(
+ reverse('admin_panel:customerNew', kwargs={'c_id': self.customer.id}),
+ ).content.decode('utf-8')
+ self.assertNotIn(
+ reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
+ html,
+ )
+ self.assertNotIn(
+ reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
+ html,
+ )
+
+ def test_sms_page_holds_all_settings_controls(self):
+ """همهٔ کنترلهای تنظیمات روی صفحهٔ پیامک هستند: نوعها، شمارهٔ اصلی، ارسال."""
+ html = self.client.get(self._url()).content.decode('utf-8')
+ self.assertIn(
+ reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
+ html,
+ )
+ self.assertIn(
+ reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
+ html,
+ )
+ self.assertIn(
+ reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
+ html,
+ )
+ # یک ردیف برای هر نوع پیامک (۸ نوع) و یک کلید for each.
+ self.assertEqual(html.count('name="sms_type"'), len(SMSType.choices))
+ self.assertEqual(html.count('role="switch"'), len(SMSType.choices))
+
+ def test_settings_live_in_modals_not_in_history_flow(self):
+ """تنظیمات داخل مودال است تا فهرست سوابق شلوغ نشود."""
+ html = self.client.get(self._url()).content.decode('utf-8')
+ self.assertIn('id="smsSettingsModal"', html)
+ self.assertIn('id="smsCustomModal"', html)
+ self.assertIn('js-csmsx-open', html)
+
+ def test_stats_are_computed_for_the_header(self):
+ self._item(status=SMSQueue.Status.SENT)
+ self._item(status=SMSQueue.Status.PENDING)
+ self._item(status=SMSQueue.Status.FAILED)
+ response = self.client.get(self._url())
+ self.assertEqual(response.context['stat_sent'], 1)
+ self.assertEqual(response.context['stat_pending'], 1)
+ self.assertEqual(response.context['stat_failed'], 1)
+ self.assertEqual(response.context['total_count'], 3)
+
+ def test_toggle_redirects_back_to_sms_page_with_open_settings(self):
+ """پس از تغییر تنظیم، کاربر در همان صفحه/مودال میماند (نه صفحهٔ مشتری)."""
+ response = self.client.post(
+ reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
+ {'sms_type': SMSType.WELCOME, 'is_enabled': 'false'},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(response.url.startswith(self._url()), response.url)
+ self.assertIn('open=settings', response.url)
+ self.assertIn('notice=customer_sms_saved', response.url)
+
+ def test_custom_send_redirects_back_to_sms_page(self):
+ mark_primary(self.customer, '09120000000')
+ response = self.client.post(
+ reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
+ {'text': 'سلام {name}'},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(response.url.startswith(self._url()), response.url)
+ self.assertIn('notice=custom_queued', response.url)
+
+ def test_primary_toggle_redirects_back_to_sms_page(self):
+ response = self.client.post(
+ reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
+ {'phone': '09120000000', 'is_primary': 'true'},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(response.url.startswith(self._url()), response.url)
+
+ def test_open_settings_query_keeps_modal_available(self):
+ response = self.client.get(self._url() + '?open=settings')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn('id="smsSettingsModal"', response.content.decode('utf-8'))
+
class SmsPartsTests(TestCase):
"""محاسبهٔ «تعداد پیامکِ محاسبهشده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""