some improve

This commit is contained in:
javad committed 2026-10-27 11:49:09 +03:30
1 parent fa73a734a0
commit 2e5d0bcdd3
17 files changed
+1512 -226

No files matched your search

+56 -5
View File
@@ -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'))
+2 -5
View File
@@ -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)
# پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
+79 -13
View File
@@ -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)