This commit is contained in:
javad committed 2026-10-11 19:04:20 +03:30
1 parent 1dea3509a7
commit e3a6d1f9d4
42 files changed
+33086 -15779

No files matched your search

@@ -0,0 +1,156 @@
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدام‌های «بازدیدِ تمام‌شده».
فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ
آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست می‌کند. این مقدار باید برابر
«بزرگ‌ترین ``finishDateTime`` میان اقدام‌های ``actionType='bazdid'`` و ``status='finish'``
هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction``
می‌نویسند.
اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)،
این فیلد عقب می‌ماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده می‌شود. این
دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم می‌کند.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری
python manage.py recalc_last_bazdid # اعمال اصلاحات
مشخصات:
* idempotent — اجرای دوباره پس از یک‌بار اجرا هیچ تغییری ایجاد نمی‌کند.
* پیش‌فرض محافظه‌کارانه است: فقط مقدار را **به‌روز** می‌کند و هیچ‌وقت روی مقادیری که
اقدامِ پشتیبان ندارند دست نمی‌گذارد (مگر با ``--clear-orphans``).
* روی دیتابیس production هم قابل اجرا است.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Max
from ...models import Action, Customer
class Command(BaseCommand):
help = (
"بازمحاسبهٔ Customer.last_bazdid_dt از MAX(finishDateTime) اقدام‌های "
"بازدیدِ تمام‌شده (ترمیم واگرایی داده؛ idempotent)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ هیچ رکوردی ذخیره نمی‌شود.",
)
parser.add_argument(
"--clear-orphans", action="store_true",
help="مقدارهایی که هیچ اقدامِ بازدیدِ تمام‌شده‌ای پشتشان نیست را هم NULL کن "
"(پیش‌فرض: دست‌نخورده بمانند). فقط اگر مطمئنی این ستون آلوده است.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان در هر گام (پیش‌فرض ۵۰۰).",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
clear_orphans = options["clear_orphans"]
batch_size = max(1, options["batch_size"])
prefix = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(self.style.MIGRATE_HEADING(
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
# منبع حقیقت: بزرگ‌ترین زمانِ پایانِ بازدید در اقدام‌های تمام‌شدهٔ هر مشتری.
# (Max مقادیر NULL را نادیده می‌گیرد؛ مشتریِ بدون بازدید به‌کل در نقشه نمی‌آید.)
latest_rows = (
Action.objects
.filter(actionType="bazdid", status="finish")
.exclude(customer_id__isnull=True)
.values("customer_id")
.annotate(mx=Max("finishDateTime"))
)
latest = {
row["customer_id"]: row["mx"]
for row in latest_rows.iterator(chunk_size=batch_size)
if row["mx"] is not None
}
stats = {
"scanned": 0, # کل مشتریان بررسی‌شده
"with_visit": 0, # مشتریان دارای بازدیدِ تمام‌شده
"updated": 0, # مقدار اصلاح/به‌روز شد
"moved_forward": 0, # جدیدتر شد ⇒ از لیست معوق‌ها خارج می‌شود
"moved_backward": 0, # عقب‌تر شد
"set_null": 0, # با --clear-orphans خالی شد
"already_ok": 0, # از قبل درست بود
"kept_orphan": 0, # مقدار داشت ولی اقدامِ پشتیبان نداشت (دست‌نخورده ماند)
"null_and_no_action": 0, # NULL و بدون اقدام (طبیعی؛ کاری لازم نیست)
}
pending = [] # (customer_id, target_value) برای اعمال گروهی
qs = Customer.objects.only("id", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
mx = latest.get(customer.id)
current = customer.last_bazdid_dt
if mx is not None:
stats["with_visit"] += 1
if current == mx:
stats["already_ok"] += 1
continue
if current is None or current < mx:
stats["moved_forward"] += 1
else:
stats["moved_backward"] += 1
stats["updated"] += 1
pending.append((customer.id, mx))
else:
# هیچ بازدیدِ تمام‌شده‌ای پشتوانهٔ این مقدار نیست.
if current is None:
stats["null_and_no_action"] += 1
elif clear_orphans:
stats["set_null"] += 1
pending.append((customer.id, None))
else:
stats["kept_orphan"] += 1
if not dry_run and pending:
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(
last_bazdid_dt=target
)
self._report(stats, dry_run, clear_orphans)
def _report(self, stats, dry_run, clear_orphans):
style = self.style
label = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:"))
self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}")
self.stdout.write(f" دارای بازدیدِ تمام‌شده: {stats['with_visit']:,}")
self.stdout.write(f" از قبل درست: {stats['already_ok']:,}")
self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}")
self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}")
self.stdout.write(f" → عقب‌تر شد: {stats['moved_backward']:,}")
if clear_orphans:
self.stdout.write(f" → خالی شد (بدون اقدام): {stats['set_null']:,}")
else:
self.stdout.write(
f" بدون اقدام ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(
f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}"
)
if dry_run:
self.stdout.write(style.WARNING(
"\n[حالت شبیه‌سازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن."
))
elif stats["updated"]:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {stats['updated']:,} رکورد به‌روزرسانی شد."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
))
@@ -0,0 +1,174 @@
"""کاهش انواع پیامک به همان ۷ نوع مصوب + هم‌راستاسازی قالب‌ها.
* سه نوع قدیمی (``cheque_due``، ``debt_due``، ``custom``) حذف می‌شوند:
قالب‌ها و تنظیمات opt-out مربوط به آن‌ها پاک می‌شود.
* دو نوع جدید اضافه می‌شود: ``visit_created`` (ثبت اقدام بازدید) و
``payment_thanks`` (تشکر از پرداخت).
* متن/زمان‌بندی نوع‌های باقی‌مانده به معنای جدید هم‌راستا می‌شود
(مثلاً «تأمین موجودی چک» = چند روز پس از سررسید، و نظرسنجی = بی‌درنگ پس از فاکتور).
"""
from django.db import migrations, models
SMS_TYPE_CHOICES = [
("welcome", "خوش‌آمدگویی (ایجاد مشتری)"),
("survey", "نظرسنجی پس از ثبت فاکتور"),
("cheque_registration", "یادآوری تأمین موجودی چک"),
("visit", "یادآوری بازدید خدمات دستگاه"),
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
("payment_thanks", "تشکر از پرداخت"),
("nesiye_due", "یادآوری سررسید نسیه"),
]
REMOVED_TYPES = ["cheque_due", "debt_due", "custom"]
# نوع → مقادیر پیش‌فرض (خالی‌نماندن قالب‌های جدید و هم‌راستایی نوع‌های تغییریافته).
DEFAULT_TEMPLATES = {
"welcome": {
"title": "خوش‌آمدگویی",
"text": "{name} عزیز، از خرید شما سپاسگزاریم. به‌امید دیدار دوباره.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"survey": {
"title": "نظرسنجی پس از فاکتور",
"text": "{name} عزیز، از خرید شما سپاسگزاریم. لطفاً با ثبت نظر خود در "
"نظرسنجی ما را همراهی کنید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"cheque_registration": {
"title": "یادآوری تأمین موجودی چک",
"text": "{name} عزیز، چک شما به مبلغ {amount_sep} ریال سررسید شده است. "
"لطفاً نسبت به تأمین موجودی حساب اقدام فرمایید.",
"offset_days": 1,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"visit": {
"title": "یادآوری بازدید خدمات دستگاه",
"text": "{name} عزیز، بیش از ۹ ماه از تعویض فیلتر دستگاه شما گذشته است. "
"لطفاً جهت هماهنگی برای ثبت بازدید رایگان با تلفن 09139741111 "
"تماس بگیرید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": 9,
},
"visit_created": {
"title": "ثبت بازدید",
"text": "{name} عزیز، بازدید شما برای تاریخ {date} ثبت شد. "
"از همکاری شما سپاسگزاریم.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"payment_thanks": {
"title": "تشکر از پرداخت",
"text": "{name} عزیز، از پرداخت شما به مبلغ {amount_sep} ریال سپاسگزاریم.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
"nesiye_due": {
"title": "یادآوری سررسید نسیه",
"text": "{name} عزیز، سررسید بدهی شما به مبلغ {amount_sep} ریال نزدیک است "
"({date}). لطفاً نسبت به تسویه اقدام فرمایید.",
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
},
}
def align_templates(apps, schema_editor):
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
CustomerSMSSetting = apps.get_model("admin_panel", "CustomerSMSSetting")
# ۱. پاک‌سازی انواع حذف‌شده.
SMSTemplate.objects.filter(sms_type__in=REMOVED_TYPES).delete()
CustomerSMSSetting.objects.filter(sms_type__in=REMOVED_TYPES).delete()
# ۲. ایجاد/به‌روزرسانی قالب‌های ۷ نوع مجاز (متن پیش‌فرض جدید + زمان‌بندی).
for sms_type, data in DEFAULT_TEMPLATES.items():
_template, created = SMSTemplate.objects.get_or_create(
sms_type=sms_type,
defaults={
"title": data["title"],
"text": data["text"],
"is_enabled": True,
"offset_days": data["offset_days"],
"repeat_interval_days": data["repeat_interval_days"],
"repeat_interval_months": data["repeat_interval_months"],
},
)
if not created:
SMSTemplate.objects.filter(sms_type=sms_type).update(
offset_days=data["offset_days"],
repeat_interval_days=data["repeat_interval_days"],
repeat_interval_months=data["repeat_interval_months"],
)
# ۳. متن/عنوانِ نوع‌های تغییریافته به معنای جدید هم‌راستا می‌شود.
SMSTemplate.objects.filter(sms_type="visit").update(
title=DEFAULT_TEMPLATES["visit"]["title"],
text=DEFAULT_TEMPLATES["visit"]["text"],
)
SMSTemplate.objects.filter(sms_type="cheque_registration").update(
title=DEFAULT_TEMPLATES["cheque_registration"]["title"],
text=DEFAULT_TEMPLATES["cheque_registration"]["text"],
)
SMSTemplate.objects.filter(sms_type="survey").update(
title=DEFAULT_TEMPLATES["survey"]["title"],
text=DEFAULT_TEMPLATES["survey"]["text"],
)
def clear_removed_queue_types(apps, schema_editor):
"""نوعِ ردیف‌های صفِ باقی‌مانده از انواع حذف‌شده را خالی می‌کند.
ردیف صف حذف نمی‌شود (متن و تاریخچه‌اش می‌ماند) چون ممکن است هنوز ارسال‌نشده
باشد؛ فقط ``sms_type`` آن خالی می‌شود تا کدِ نوعِ نامعتبر در دیتابیس نماند.
"""
SMSQueue = apps.get_model("admin_panel", "SMSQueue")
SMSQueue.objects.filter(sms_type__in=REMOVED_TYPES).update(sms_type="")
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0069_smstemplate_auto_send"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, unique=True,
verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="customersmssetting",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="smsqueue",
name="sms_type",
field=models.CharField(
blank=True, choices=SMS_TYPE_CHOICES, default="", max_length=40,
verbose_name="نوع",
),
),
migrations.RunPython(align_templates, noop),
migrations.RunPython(clear_removed_queue_types, noop),
]
@@ -0,0 +1,47 @@
"""افزودن پرمیشن جداگانهٔ ``ManageSMSSettings`` برای صفحات تنظیمات/قالب‌های پیامک.
پیش از این، همهٔ مسیرهای پیامک با ``ManageSMS`` کنترل می‌شد. از این پس صفحات
«تنظیمات سامانه» و «قالب‌ها» با ``ManageSMSSettings`` جدا می‌شوند تا مدیر بتواند
دسترسی به آن‌ها را فقط به کاربران مشخصی بدهد، بدون دادن کل اختیارات پیامک.
"""
from django.db import migrations
from django.db.models import F
def ensure_permission(apps, schema_editor):
"""رکورد Permission جدید را (اگر نبود) می‌سازد و نام نمایشی‌اش را اصلاح می‌کند."""
Permission = apps.get_model('auth', 'Permission')
ContentType = apps.get_model('contenttypes', 'ContentType')
content_type, _created = ContentType.objects.get_or_create(
app_label='admin_panel', model='smsproviderconfig',
)
Permission.objects.get_or_create(
content_type=content_type,
codename='ManageSMSSettings',
defaults={'name': 'ManageSMSSettings'},
)
# نام نمایشی هر دو پرمیشن پیامک را با قرارداد پروژه هم‌راستا می‌کند.
Permission.objects.filter(
content_type=content_type, codename__in=['ManageSMS', 'ManageSMSSettings'],
).update(name=F('codename'))
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0070_sms_types_seven'),
]
operations = [
migrations.AlterModelOptions(
name='smsproviderconfig',
options={
'permissions': [('ManageSMS', 'ManageSMS'),
('ManageSMSSettings', 'ManageSMSSettings')],
'verbose_name': 'تنظیمات سامانه پیامک',
'verbose_name_plural': 'تنظیمات سامانه پیامک',
},
),
migrations.RunPython(ensure_permission, migrations.RunPython.noop),
]
+14 -7
View File
@@ -11,16 +11,20 @@ from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم."""
"""انواع پیامک‌های سیستم (دقیقاً ۷ نوع، به ترتیب چرخهٔ کاری).
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
اسکنر دوره‌ای در صف قرار می‌گیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
و ``admin_panel/sms/scanner.py`` است.
"""
WELCOME = "welcome", "خوش‌آمدگویی (ایجاد مشتری)"
CHEQUE_DUE = "cheque_due", "یادآوری سررسید چک"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
SURVEY = "survey", "نظرسنجی پس از ثبت فاکتور"
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
DEBT_DUE = "debt_due", "یادآوری بدهی (سررسید وصول مطالبات)"
VISIT = "visit", "یادآوری بازدید خدمات"
SURVEY = "survey", "نظرسنجی پس از فاکتور"
CUSTOM = "custom", "پیام سفارشی"
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
class SMSProviderConfig(models.Model):
@@ -33,6 +37,9 @@ class SMSProviderConfig(models.Model):
# نام نمایشی مطابق قرارداد سایر پرمیشن‌های پروژه (ViewStore/AddCustomer/...)
# تا در فهرست پرمیشن‌های Django Admin به‌سادگی پیدا شود.
("ManageSMS", "ManageSMS"),
# سطح دسترسی جداگانه برای صفحات «تنظیمات» و «قالب‌ها»: مدیر می‌تواند فقط
# به کاربران مشخصی اجازهٔ دیدن/ویرایش این‌ها را بدهد (نه هر کاربر پیامک).
("ManageSMSSettings", "ManageSMSSettings"),
]
ProviderChoices = [
+4
View File
@@ -30,6 +30,10 @@ NOTICES = {
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
"deleted": ("success", "پیامک از صف حذف شد."),
"delete_done_blocked": (
"warning",
"این پیامک ارسال شده است و از صف حذف نمی‌شود؛ سابقهٔ آن در «گزارش ارسال» می‌ماند.",
),
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
+90
View File
@@ -0,0 +1,90 @@
"""محاسبهٔ «تعداد پیامکِ محاسبه‌شده» (parts) و کدگذاری متن.
هر پیامک روی شبکهٔ موبایل بر اساس **کدگذاری** و **طول** به یک یا چند «بخش» (part)
شکسته می‌شود و اپراتور بر همان تعداد بخش هزینه می‌گیرد. این ماژول همان قاعدهٔ
استاندارد (3GPP/GSM 03.38) را پیاده می‌کند تا بتوان هزینهٔ ارسال را تحلیل کرد:
* **GSM-7** — برای متن‌های انگلیسی/عددی. هر بخش ۱۶۰ کاراکتر؛ در حالت چندبخشی
(multipart) هر بخش ۱۵۳ کاراکتر (۷ کاراکتر برای هدر الحاق).
برخی کاراکترها (مثل `{}[]~\\|^€`) در جدول «الحاقی» هستند و **۲ کاراکتر** حساب
می‌شوند.
* **UCS-2** — اگر متن حتی **یک** کاراکتر خارج از جدول GSM-7 داشته باشد (فارسی،
عربی، ایموجی…)، **کل پیامک** با UCS-2 کدگذاری می‌شود: هر بخش ۷۰ کاراکتر؛ در
حالت چندبخشی هر بخش ۶۷ کاراکتر. ایموجی‌ها (خارج از BMP) دو واحد کدِ UTF-16
حساب می‌شوند.
بنابراین یک متن فارسی حتی اگر کوتاه باشد، در «۷۰ کاراکتر» جا می‌شود و ممکن است
چند پیامک حساب شود؛ و یک متن ترکیبی فارسی+انگلیسی همیشه UCS-2 است.
"""
from math import ceil
ENCODING_GSM7 = "gsm7"
ENCODING_UCS2 = "ucs2"
# جدول کامل GSM 03.38 (بخش پایه). کاراکتر 0x1B عمداً وارد نشده چون نقش «پیشوند
# الحاق» دارد و کاراکتر مستقل قانونی نیست.
GSM7_BASIC = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ"
" !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
# کاراکترهای «جدول الحاقی»: در GSM-7 دو واحد حساب می‌شوند.
GSM7_EXTENDED = set("^{}\\[~]|€")
# (سقف تک‌بخشی، سقف هر بخش چندبخشی) برای هر کدگذاری.
LIMITS = {
ENCODING_GSM7: (160, 153),
ENCODING_UCS2: (70, 67),
}
ENCODING_LABELS = {
ENCODING_GSM7: "GSM-7 (انگلیسی/عدد)",
ENCODING_UCS2: "UCS-2 (فارسی/یونیکد)",
}
def is_gsm7(text):
"""آیا کل متن با جدول GSM-7 قابل نمایش است؟ (شرط کافی و لازم برای GSM-7)."""
return all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text or "")
def detect_encoding(text):
"""کدگذاری متن: ``gsm7`` یا ``ucs2``. متن خالی = ``gsm7``."""
return ENCODING_GSM7 if is_gsm7(text) else ENCODING_UCS2
def encoded_length(text):
"""طول متن بر حسب «واحدهایی که اپراتور می‌شمارد».
* GSM-7: کاراکترهای الحاقی ۲ واحد.
* UCS-2: واحدهای کد UTF-16 (ایموجی = ۲).
"""
text = text or ""
if is_gsm7(text):
return sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)
return len(text.encode("utf-16-le")) // 2
def parts_count(text):
"""تعداد بخش‌هایی که اپراتور برای این متن حساب می‌کند (۱ برای متن خالی = ۰)."""
text = text or ""
if not text:
return 0
single, multipart = LIMITS[detect_encoding(text)]
length = encoded_length(text)
if length <= single:
return 1
return ceil(length / multipart)
def describe(text):
"""خلاصهٔ محاسبه برای نمایش در گزارش (کدگذاری، طول، تعداد بخش)."""
encoding = detect_encoding(text)
return {
"encoding": encoding,
"encoding_label": ENCODING_LABELS[encoding],
"length": encoded_length(text),
"parts": parts_count(text),
}
+1 -2
View File
@@ -4,7 +4,7 @@ from ..models import CustomerSMSSetting, SMSType
def is_sms_enabled(customer, sms_type):
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال."""
if customer is None or sms_type == SMSType.CUSTOM:
if customer is None:
return True
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
if setting is None:
@@ -21,5 +21,4 @@ def customer_toggle_map(customer):
return [
{"code": code, "label": label, "enabled": overrides.get(code, True)}
for code, label in SMSType.choices
if code != SMSType.CUSTOM
]
+27 -137
View File
@@ -9,31 +9,29 @@
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
اسکنرها فقط انواع «دوره‌ای» را پوشش می‌دهند (تأمین موجودی چک، سررسید نسیه، بازدید
دوره‌ای). انواع «رویدادی» (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از پرداخت) هنگام
وقوع رخداد توسط ویوها و از طریق ``admin_panel/sms/triggers.py`` صف می‌شوند.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره
ساخته می‌شود؛ برای همیشه خاموش نمی‌ماند.
یادآوری‌های **دوره‌ای** (بازدید) کلیدشان شامل شمارهٔ دوره است. یعنی اگر مدیر یک مورد
را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره ساخته می‌شود؛ برای
همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.db.models import Q, Sum
from django.utils import timezone
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
SMSTemplate, SMSType)
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# پیش‌فرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
CHEQUE_REG_INTERVAL_DEFAULT = 3
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
@@ -82,33 +80,39 @@ class _Budget:
self.new_count += 1
def scan_cheque_due(today):
template = _template(SMSType.CHEQUE_DUE)
def scan_cheque_funding(today):
"""یادآوری «تأمین موجودی چک» چند روز پس از سررسید چکِ پرداخت‌نشده.
چند روزِ پس از سررسید از قالب همین نوع (قابل تنظیم از پنل) خوانده می‌شود و مبلغ
چک در متن قرار می‌گیرد. اگر چک پرداخت شود (``payStatus=true``) دیگر یادآوری
نمی‌گیرد؛ ``dedup_key`` نیز مانع ارسال تکراری در اجراهای بعدی می‌شود. اگر اسکنر
چند روز از دست برود، چک‌های «معوق‌تر» هم واجد شرط می‌شوند تا یادآوری از دست نرود.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
type="check", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
type="check", payStatus="false", sarresidDT__date__lte=target,
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
# پنجرهٔ یادآوری از «سررسید منهای offset» باز می‌شود.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
# لحظهٔ باز شدن پنجرهٔ یادآوری = سررسید + offset روز.
trigger = _aware(s.sarresidDT) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
days = (today - s.sarresidDT.date()).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_DUE,
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
},
dedup_key=f"cheque_due:{s.id}",
dedup_key=f"cheque_fund:{s.id}",
)
if made:
budget.record(trigger)
@@ -149,119 +153,6 @@ def scan_nesiye_due(today):
return count
def scan_cheque_registration(today):
"""یادآوری «ثبت اطلاعات چک» برای چک‌های پرداخت‌نشدهٔ ثبت‌نشده.
هر ``interval`` روز یک‌بار تکرار می‌شود و به‌محض اینکه شمارهٔ صیادی چک ثبت شود
(یا چک پرداخت شود) متوقف می‌گردد. دورهٔ تکرار از پنل قابل تنظیم است.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__gte=today,
).filter(
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
).select_related("customer")
count = 0
for s in qs:
if not s.customer:
continue
anchor = s.createdDT or s.sarresidDT
if not anchor:
continue
days = (today - anchor.date()).days
if days <= 0 or days % interval != 0:
continue
cycle = days // interval
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
trigger = _aware(anchor) + timedelta(days=days)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days
if s.sarresidDT else "",
"check_row": s.check_row_number,
},
dedup_key=f"cheque_reg:{s.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_debt_due(today):
"""یادآوری بدهی برای مانده‌دارها روی سررسید وصول مطالبات."""
template = _template(SMSType.DEBT_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
payStatus="false", sarresidDT__date=target,
).select_related("customer").order_by("sarresidDT")
count = 0
seen = set()
for s in qs:
if not s.customer or s.customer_id in seen or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT)
if not budget.can_send(trigger):
continue
totals = Article.objects.filter(customer=s.customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
net = (totals["d"] or 0) - (totals["c"] or 0)
if net <= 0:
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.DEBT_DUE,
context={"name": s.customer.name_family, "amount": net},
dedup_key=f"debt_due:{s.id}",
)
if made:
seen.add(s.customer_id)
budget.record(trigger)
count += made
return count
def scan_survey(today):
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
template = _template(SMSType.SURVEY)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 3
target = today - timedelta(days=offset)
qs = Factor.objects.filter(
is_active=True, create_date__date=target,
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
count = 0
for f in qs:
if not f.customer or not f.create_date:
continue
trigger = _aware(f.create_date) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=f.customer, sms_type=SMSType.SURVEY,
context={"name": f.customer.name_family, "amount": f.amount},
dedup_key=f"survey:{f.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
@@ -313,12 +204,11 @@ def scan_visit(today):
return count
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف می‌شوند، نه این‌جا.
SCANNERS = [
("cheque_due", scan_cheque_due),
("cheque_registration", scan_cheque_funding),
("nesiye_due", scan_nesiye_due),
("cheque_registration", scan_cheque_registration),
("debt_due", scan_debt_due),
("survey", scan_survey),
("visit", scan_visit),
]
+24 -34
View File
@@ -9,8 +9,7 @@ from ..models import SMSType
NONE = "none"
BEFORE_DUE = "before_due"
AFTER_CREATION = "after_creation"
RECURRING = "recurring"
AFTER_DUE = "after_due"
PERIOD_MONTHS = "period_months"
# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
@@ -22,39 +21,18 @@ TIMING = {
"field": None,
"note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید چک",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": RECURRING,
"field": "repeat_interval_days",
"label": "هر چند روز یک‌بار",
"default": 3,
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
"با این فاصله تکرار می‌شود.",
},
SMSType.SURVEY: {
"kind": AFTER_CREATION,
"field": "offset_days",
"label": "چند روز بعد از صدور فاکتور",
"default": 3,
"note": "این تعداد روز پس از ثبت فاکتور ارسال می‌شود.",
},
SMSType.DEBT_DUE: {
"kind": NONE,
"field": None,
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال می‌شود.",
"note": "بی‌درنگ پس از ثبت فاکتور ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": AFTER_DUE,
"field": "offset_days",
"label": "چند روز پس از سررسید چک",
"default": 1,
"note": "این تعداد روز پس از سررسید چکِ پرداخت‌نشده، پیامک یادآوری "
"تأمین موجودی با مبلغ چک ارسال می‌شود.",
},
SMSType.VISIT: {
"kind": PERIOD_MONTHS,
@@ -63,10 +41,22 @@ TIMING = {
"default": DEFAULT_VISIT_MONTHS,
"note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.",
},
SMSType.CUSTOM: {
SMSType.VISIT_CREATED: {
"kind": NONE,
"field": None,
"note": "فقط به‌صورت دستی از صفحهٔ مشتری ارسال می‌شود؛ تنظیم زمانی ندارد.",
"note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.PAYMENT_THANKS: {
"kind": NONE,
"field": None,
"note": "بی‌درنگ پس از ثبت پرداخت (سند وصول/بستانکار) ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 0,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
}
+82
View File
@@ -0,0 +1,82 @@
"""تریگرهای رویدادی پیامک (لایهٔ سرویس، بدون signal).
چهار نوع پیامک «رویدادی» هستند و لحظهٔ وقوع‌شان یک اکشن پنل است، نه یک سررسید
تاریخی؛ پس به‌جای اسکنر دوره‌ای، ویوها بعد از انجام کارِ اصلی این توابع را صدا
می‌زنند:
* :func:`notify_welcome` — ایجاد مشتری.
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
قرارداد پروژه: استفاده از signal ممنوع است؛ همه‌چیز صریح از دلِ ویو صدا زده می‌شود.
این توابع هیچ استثنایی به فراخوان پرتاب نمی‌کنند (``enqueue_sms`` خودش مقاوم است) تا
مسیر اصلی هرگز به‌خاطر پیامک مختل نشود.
"""
import logging
from ..models import SMSType
from .enqueue import enqueue_sms
logger = logging.getLogger(__name__)
# روش‌های «غیرپرداخت»: سند بستانکاری که به‌خاطر تخفیف صادر شده، پرداختِ واقعی نیست
# و نباید پیامک «تشکر از پرداخت» بگیرد.
NON_PAYMENT_METHODS = {"تخفیف"}
def notify_welcome(customer, *, created_by=None):
"""پیامک خوش‌آمدگویی در لحظهٔ ساخت مشتری."""
if customer is None or customer.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.WELCOME,
context={"name": customer.name_family},
dedup_key=f"welcome:{customer.pk}",
created_by=created_by,
)
def notify_factor_survey(customer, factor, *, created_by=None):
"""پیامک نظرسنجی، بی‌درنگ پس از ثبت فاکتور."""
if customer is None or factor is None or factor.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.SURVEY,
context={"name": customer.name_family, "amount": factor.amount},
dedup_key=f"survey:{factor.pk}",
created_by=created_by,
)
def notify_visit_scheduled(customer, action, *, created_by=None):
"""پیامک «بازدید شما برای تاریخ … ثبت شد» پس از ایجاد اقدام بازدید."""
if customer is None or action is None or action.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.VISIT_CREATED,
context={"name": customer.name_family, "date": action.actionDateTime},
dedup_key=f"visit_created:{action.pk}",
created_by=created_by,
)
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
created_by=None):
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
``article_id`` برای یکتاسازی استفاده می‌شود تا یک پرداخت هرگز دو پیامک نگیرد.
``method`` نحوهٔ پرداخت است؛ اگر «تخفیف» باشد (که پرداختِ واقعی نیست) هیچ پیامکی
ساخته نمی‌شود.
"""
if customer is None:
return 0
if method and str(method).strip() in NON_PAYMENT_METHODS:
return 0
dedup_key = f"payment_thanks:{article_id}" if article_id is not None else None
return enqueue_sms(
customer=customer, sms_type=SMSType.PAYMENT_THANKS,
context={"name": customer.name_family, "amount": amount},
dedup_key=dedup_key,
created_by=created_by,
)
+2 -1
View File
@@ -129,4 +129,5 @@ input[disabled]{
border-right: 1px solid #b7b7b7;
text-align: center;
border-left: 1px solid #b7b7b7;
}
}
@@ -0,0 +1,144 @@
{% load custom_tags %}
{% comment %}
کارت پروفایل مشتری — ستون سمت راست صفحهٔ مشتری.
تک‌منبع (DRY): هم customerBase.html و هم customerBaseNew.html این را include می‌کنند
تا ساختار، استایل و اسکریپت ویرایش/شماره‌ها در همهٔ صفحات مشتری یکسان بماند.
شناسه‌های موردنیاز جاوااسکریپت:
#formcsEdit, #enEdit, #editCs, #cancelEdit, #del_customer, #showUserDT, #userDT
{% endcomment %}
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">{{ customer.name_family }}</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
{% include "./_customer_phones.html" with phones_editable=1 %}
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input ss02 installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode ss02" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
<select name="laghv_bazdid" style="width: 100%" class="select" id="LaghvBazdid">
<option value="false" {% if not customer.laghv_bazdid %}selected{% endif %}>خیر</option>
<option value="true" {% if customer.laghv_bazdid %}selected{% endif %}>بله</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<span class="button is-light is-hidden" id="cancelEdit">انصراف</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
<script>
// اسکریپت مشترک کارت پروفایل مشتری: ویرایش/انصراف، افزودن و حذف شماره،
// باز/بستن در موبایل و حذف مشتری. (یک‌بار bind می‌شود.)
(function () {
if (window.__customerInfoCardInit) { return; }
window.__customerInfoCardInit = true;
// --- ورود به حالت ویرایش ---
$(document).on('click', '#enEdit', function () {
$('#formcsEdit').find('input, textarea, select').prop('disabled', false);
$('#formcsEdit').find('.remove-phone-btn, .add-phone-btn').prop('disabled', false);
$('#enEdit').addClass('is-hidden');
$('#cancelEdit').removeClass('is-hidden');
$('#editCs').removeClass('is-hidden');
});
// --- انصراف: بارگذاری مجدد؛ تغییرات ذخیره‌نشده دور ریخته می‌شود ---
$(document).on('click', '#cancelEdit', function () {
window.location.reload();
});
// --- افزودن شماره (در حالت ویرایش) ---
$(document).on('click', '.add-phone-btn', function () {
var phoneRow = `
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 30%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شمارهٔ تماس جدید">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
</div>
</div>`;
$('#dynamic-phones-edit').append(phoneRow);
});
// --- حذف شماره (حداقل یک شماره باید بماند) ---
$(document).on('click', '.remove-phone-btn', function () {
if ($('#dynamic-phones-edit .field.has-addons').length > 1) {
$(this).closest('.field.has-addons').remove();
} else {
window.alert('حداقل یک شمارهٔ تماس باید وجود داشته باشد.');
}
});
// --- باز/بستن مشخصات در موبایل ---
$(document).on('click', '#showUserDT', function () {
var $btn = $(this);
if ($btn.text().trim() === 'مشاهده مشخصات') {
$btn.text('مخفی کردن');
$('#userDT').removeClass('is-hidden-mobile');
} else {
$btn.text('مشاهده مشخصات');
$('#userDT').addClass('is-hidden-mobile');
}
});
// --- حذف مشتری ---
$(document).on('click', '#del_customer', function () {
if (window.confirm('با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف می‌شود، مطمئن هستید؟')) {
window.location.href = '/panel/customer/{{ customer.id }}/del';
}
});
})();
</script>
@@ -19,6 +19,16 @@
📞
</a>
</div>
{% if perms.admin_panel.ManageSMS and entry.is_mobile %}
<div class="control">
<button type="button"
class="button sms-primary-toggle {% if entry.is_primary %}is-warning{% else %}is-light{% endif %}"
data-url="{% url 'admin_panel:sms_primary_toggle' customer.id %}"
data-phone="{{ entry.raw }}"
data-primary="{% if entry.is_primary %}true{% else %}false{% endif %}"
title="{% if entry.is_primary %}برداشتن از شماره‌های اصلی پیامک{% else %}تعیین به‌عنوان شمارهٔ اصلی پیامک{% endif %}">{% if entry.is_primary %}★ اصلی{% else %}☆ اصلی{% endif %}</button>
</div>
{% endif %}
{% if phones_editable and perms.admin_panel.ViewStore %}
<div class="control">
<button type="button" disabled class="button is-danger remove-phone-btn">-</button>
@@ -35,3 +45,50 @@
</button>
{% endif %}
</div>
{% if perms.admin_panel.ManageSMS %}
<script>
// تعیین شمارهٔ اصلی پیامک از همین ستون (بدون فرم تودرتو) — با درخواست async و
// بارگذاری مجدد صفحه تا وضعیت شماره‌ها و رنگ دکمه‌ها به‌درستی به‌روز شوند.
(function () {
if (window.__smsPrimaryToggleInit) { return; }
window.__smsPrimaryToggleInit = true;
function csrfToken() {
var match = document.cookie.match(/(^|;\s*)csrftoken=([^;]+)/);
return match ? decodeURIComponent(match[2]) : '';
}
document.addEventListener('click', function (event) {
var btn = event.target.closest ? event.target.closest('.sms-primary-toggle') : null;
if (!btn || btn.disabled) { return; }
event.preventDefault();
var body = new URLSearchParams();
body.append('phone', btn.getAttribute('data-phone') || '');
body.append('is_primary', btn.getAttribute('data-primary') === 'true' ? 'false' : 'true');
btn.classList.add('is-loading');
btn.disabled = true;
fetch(btn.getAttribute('data-url'), {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRFToken': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: body.toString()
}).then(function (response) {
if (!response.ok) { throw new Error('HTTP ' + response.status); }
window.location.reload();
}).catch(function () {
btn.classList.remove('is-loading');
btn.disabled = false;
window.alert('خطا در تغییر شمارهٔ اصلی. لطفاً دوباره تلاش کنید.');
});
});
})();
</script>
{% endif %}
@@ -3,37 +3,7 @@
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-6-desktop is-12-mobile">
<h3 class="subtitle is-6">شماره‌های اصلی (گیرنده پیامک)</h3>
{# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند نمایش داده می‌شوند (جدول شماره‌ها یا فیلدهای قدیمی) #}
{% for entry in sms_phone_entries %}
<form method="post" action="{% url 'admin_panel:sms_primary_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="phone" value="{{ entry.raw }}">
<input type="hidden" name="is_primary" value="{% if entry.is_primary %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text"
value="{{ entry.title }} — {{ entry.display }}{% if not entry.is_mobile %} (غیرقابل ارسال پیامک){% endif %}"
disabled>
</div>
<div class="control">
{% if entry.is_mobile %}
<button class="button {% if entry.is_primary %}is-success{% else %}is-light{% endif %}" type="submit">
{% if entry.is_primary %}اصلی ✓{% else %}تعیین به‌عنوان اصلی{% endif %}
</button>
{% else %}
<button class="button is-light" type="button" disabled>غیرموبایل</button>
{% endif %}
</div>
</form>
{% empty %}
<p class="help">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endfor %}
<p class="help">اگر شمارهٔ اصلی تعیین نشده باشد، شمارهٔ اولِ معتبر به‌صورت خودکار استفاده می‌شود.</p>
<p class="help">گیرندگان فعلی: {% for r in sms_recipients %}<span dir="ltr">{{ r }}</span>{% if not forloop.last %}، {% endif %}{% empty %}—{% endfor %}</p>
</div>
<div class="column is-6-desktop is-12-mobile">
<div class="column is-12">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
@@ -58,7 +28,7 @@
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک... (خالی بگذارید تا متن قالب «پیام سفارشی» استفاده شود)"></textarea>
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک..."></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
+8 -1
View File
@@ -120,19 +120,26 @@
{% if perms.admin_panel.ManageSMS %}
{% if perms.admin_panel.ManageSMS or perms.admin_panel.ManageSMSSettings %}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">پیامک</a>
<div class="navbar-dropdown">
{% if perms.admin_panel.ManageSMSSettings %}
<a href="{% url 'admin_panel:sms_settings' %}" style="padding-right: 10px;" class="navbar-item">
تنظیمات سامانه
</a>
<a href="{% url 'admin_panel:sms_templates' %}" style="padding-right: 10px;" class="navbar-item">
قالب‌ها
</a>
{% endif %}
{% if perms.admin_panel.ManageSMS %}
<a href="{% url 'admin_panel:sms_queue' %}" style="padding-right: 10px;" class="navbar-item">
صف ارسال
</a>
<a href="{% url 'admin_panel:sms_report' %}" style="padding-right: 10px;" class="navbar-item">
گزارش ارسال
</a>
{% endif %}
</div>
</div>
{% endif %}
@@ -6,55 +6,7 @@
<div class="">
<div class="columns m-0">
<div class="column is-12-mobile is-4-desktop">
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">محمد جواد فصاحت</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
{% include "./_customer_phones.html" %}
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode" type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
{% include "./_customer_info.html" %}
</div>
<div class="column is-12-mobile is-8-desktop">
@@ -229,11 +181,6 @@
});
$("#del_customer").click(function(){
if(confirm("با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف میشود، مطمئن هستید؟")){
window.location.href = "/panel/customer/{{ customer.id }}/del";
}
});
$(".closeNewIteam").click(function(){
$("#addNewIteam").removeClass("is-active");
@@ -318,22 +265,6 @@ $(document).ready(function() {
var test = 0
$("#enEdit").click(function(){
$("#formcsEdit").find("input").prop('disabled', false);
$("#enEdit").addClass("is-hidden");
$("#editCs").removeClass("is-hidden");
});
$("#showUserDT").click(function(){
if( $(this).html()=='مشاهده مشخصات' ){
$(this).html('مخفی کردن');
$("#userDT").removeClass("is-hidden-mobile");
}else{
$(this).html('مشاهده مشخصات');
$("#userDT").addClass("is-hidden-mobile");
}
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
@@ -18,76 +18,7 @@
<div class="">
<div class="columns m-0">
<div class="column is-12-mobile is-4-desktop">
<form action="{% url 'admin_panel:customerEdit' customer.id %}" id="formcsEdit" class="has-background-white brad5" method="post">
{% csrf_token %}
<div class="buttons m-0 level p-2 is-hidden-tablet">
<p class="level-item">
{{ customer.name_family }}
</p>
<span id="showUserDT" class="button level-item">مشاهده مشخصات</span>
</div>
<div id="userDT" class="is-hidden-mobile">
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">نام ونام خانوادگی</label>
<div class="control">
<input required disabled value="{{ customer.name_family }}" name="an_name" class="input an_name" type="text" placeholder="نام نام خانوادگی">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">آدرس</label>
<div class="control">
<input disabled name="an_address" value="{{ customer.address }}" class="input an_address" type="text" placeholder="آدرس کامل">
</div>
</div>
{% include "./_customer_phones.html" with phones_editable=1 %}
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">تاریخ نصب</label>
<div class="control">
<input disabled name="installDate" value="{{ customer.installDate | toJalali }}" id="persianDateP" class="input ss02 installDate" type="text" placeholder="مثل: 1400/06/01">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">کدشناسایی</label>
<div class="control">
<input disabled value="{{ customer.idCode }}" name="idnCode" class="input idnCode ss02 " type="text" placeholder="">
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">توضیحات</label>
<div class="control">
<textarea disabled name="notes" class="textarea ss02 an_notes" rows="4" placeholder="یادداشت آزاد">{{ customer.notes }}</textarea>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<label class="label">لغو بازدید</label>
<div class="control">
<select name="laghv_bazdid" style="width: 100%" class="select" id="LaghvBazdid">
<option value="false">خیر</option>
<option value="true">بله</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
{% if perms.admin_panel.DelCustomer %}
<span class="button is-danger" id="del_customer">حذف مشتری</span>
{% endif %}
<span class="button is-success" id="enEdit">ویرایش</span>
<input class="button is-danger is-hidden" id="editCs" value="ثبت" type="submit">
</div>
</div>
</form>
{% include "./_customer_info.html" %}
</div>
<div class="column is-12-mobile is-8-desktop">
@@ -102,6 +33,7 @@
<li id="newFactorOpen" class="{% if url_name == 'customerNew' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerNew' customer.id %}">ایجاد فاکتور</a></li>
<li id="FactorHaOpen" class="{% if url_name == 'customerFactors' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerFactors' customer.id %}">فاکتورها </a></li>
<li class=""><a href="{% url 'admin_panel:customerAction' customer.id %}">بازاریابی</a></li>
{% if perms.admin_panel.ManageSMS %}<li class="{% if url_name == 'customerSmsHistory' %}is-active{% else %} {% endif %} "><a href="{% url 'admin_panel:customerSmsHistory' customer.id %}">سوابق پیامک</a></li>{% endif %}
{% endwith %}
@@ -473,11 +405,6 @@
});
$("#del_customer").click(function(){
if(confirm("با حذف مشتری، تمام سوابق، فاکتورها و اطلاعات از سیستم حذف میشود، مطمئن هستید؟")){
window.location.href = "/panel/customer/{{ customer.id }}/del";
}
});
$(".closeNewIteam").click(function(){
$("#addNewIteam").removeClass("is-active");
@@ -603,50 +530,6 @@ $(document).ready(function() {
var test = 0;
$("#enEdit").click(function(){
$("#formcsEdit").find("input, textarea").prop('disabled', false);
// فعال کردن دکمه‌های کنترل تلفن
$("#formcsEdit").find("button.remove-phone-btn, button.add-phone-btn").prop('disabled', false);
$("#enEdit").addClass("is-hidden");
$("#editCs").removeClass("is-hidden");
});
// توابع داینامیک فرم ویرایش
$(document).on('click', '.add-phone-btn', function() {
var phoneRow = `
<div class="field has-addons mt-2 ss02">
<div class="control" style="width: 35%;">
<input name="phone_titles" class="input" type="text" placeholder="عنوان">
</div>
<div class="control is-expanded">
<input name="phones" class="input an_phone" type="text" placeholder="شماره تماس جدید">
</div>
<div class="control">
<button type="button" class="button is-danger remove-phone-btn">-</button>
</div>
</div>`;
$("#dynamic-phones-edit").append(phoneRow);
});
$(document).on('click', '.remove-phone-btn', function() {
if ($("#dynamic-phones-edit .has-addons").length > 1) {
$(this).closest('.field.has-addons').remove();
} else {
alert("حداقل یک شماره تماس باید وجود داشته باشد.");
}
});
$("#showUserDT").click(function(){
if( $(this).html()=='مشاهده مشخصات' ){
$(this).html('مخفی کردن');
$("#userDT").removeClass("is-hidden-mobile");
}else{
$(this).html('مشاهده مشخصات');
$("#userDT").addClass("is-hidden-mobile");
}
});
$(".closeSearch").click(function(){
$(".SearchBox").removeClass("is-active")
});
@@ -0,0 +1,137 @@
{% extends "./customerBaseNew.html" %}
{% load custom_tags %}
{% load static %}
{% block BodyCustomer1 %}
<style>
/* جدول سوابق پیامک مشتری */
.smshist-msg {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-width: 34rem;
line-height: 1.9;
}
.smshist-meta { font-size: .78rem; line-height: 1.7; }
.smshist-badge { white-space: nowrap; }
</style>
<div id="customerSmsHistory" class="column is-12-mobile is-12-desktop" dir="rtl">
<h1 class="has-text-centered has-text-weight-bold mb-1">سوابق پیامک مشتری</h1>
<p class="has-text-centered has-text-grey smshist-meta mb-3">
همهٔ پیامک‌های ارسال‌شده برای شماره‌های این مشتری — جمعاً
<strong>{{ total_count }}</strong> پیامک.
</p>
<form method="get" class="field is-grouped is-grouped-multiline mb-3">
<div class="control">
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌های ارسال</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<div class="select">
<select name="delivery">
<option value="">همهٔ وضعیت‌های تحویل</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button is-link" type="submit">فیلتر</button></div>
{% if status or delivery %}
<div class="control">
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
</div>
{% endif %}
</form>
{% if status or delivery %}
<p class="mb-2 smshist-meta">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد</p>
{% endif %}
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
<thead>
<tr>
<th>شناسه</th>
<th style="min-width: 7rem;">نوع پیامک</th>
<th style="min-width: 8rem;">شماره گیرنده</th>
<th style="min-width: 18rem;">متن پیامک</th>
<th>وضعیت ارسال</th>
<th style="min-width: 8rem;">وضعیت تحویل</th>
<th>تاریخ ایجاد</th>
<th>تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.get_sms_type_display|default:'—' }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td class="smshist-msg">{{ item.message }}</td>
<td>
<span class="tag smshist-badge
{% if item.status == 'sent' %}is-success
{% elif item.status == 'failed' %}is-danger
{% elif item.status == 'pending' %}is-warning
{% elif item.status == 'approved' %}is-info
{% else %}is-light{% endif %}">
{{ item.get_status_display }}
</span>
{% if item.last_error %}
<div class="has-text-danger smshist-meta" title="{{ item.last_error }}">
خطا: {{ item.last_error|truncatechars:80 }}
</div>
{% endif %}
</td>
<td>
<span class="tag smshist-badge
{% if item.delivery_status == 'delivered' %}is-success
{% elif item.delivery_status == 'not_delivered' %}is-danger
{% elif item.delivery_status == 'expired' %}is-danger
{% elif item.delivery_status == 'pending' %}is-warning
{% else %}is-light{% endif %}">
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
</span>
{% if item.delivery_checked_at %}
<div class="has-text-grey smshist-meta">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
{% endif %}
</td>
<td class="smshist-meta">{{ item.created_at|toJalaliDT }}</td>
<td class="smshist-meta">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr><td colspan="8" class="has-text-centered">پیامکی برای این مشتری ثبت نشده است.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if items.has_other_pages %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if items.has_previous %}
<a class="pagination-previous" href="?page={{ items.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% else %}
<a class="pagination-previous" disabled>قبلی</a>
{% endif %}
{% if items.has_next %}
<a class="pagination-next" href="?page={{ items.next_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">بعدی</a>
{% else %}
<a class="pagination-next" disabled>بعدی</a>
{% endif %}
<ul class="pagination-list">
<li><span class="pagination-link">صفحهٔ {{ items.number }} از {{ items.paginator.num_pages }}</span></li>
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
@@ -12,6 +12,9 @@
حذف یک مورد، فقط همان نوبت را کنسل می‌کند. یادآوری‌های دوره‌ای (بازدید و ثبت
اطلاعات چک) در <strong>دورهٔ بعدی</strong> دوباره ساخته می‌شوند؛ پس با حذف یک
مورد، پیامک برای آن مشتری برای همیشه قطع نمی‌شود.
<br>
پیامک‌های <strong>ارسال‌شده</strong> از این صف بیرون می‌روند؛ فهرست و سابقهٔ
کامل‌شان در <a href="{% url 'admin_panel:sms_report' %}">گزارش ارسال</a> است.
</div>
<div class="columns is-multiline mb-2" dir="rtl">
@@ -21,6 +24,13 @@
<p class="title is-5 mb-0">{{ total_count }}</p>
</div>
</div>
<div class="column is-narrow">
<a href="{% url 'admin_panel:sms_report' %}" class="box py-3 px-4 has-text-centered"
title="مشاهدهٔ سوابق ارسال‌شده‌ها در گزارش ارسال">
<p class="heading mb-0">ارسال‌شده (در گزارش)</p>
<p class="title is-5 mb-0 has-text-success">{{ sent_count }}</p>
</a>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
@@ -71,11 +81,12 @@
</form>
<div class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between mb-3">
<span>خالی کردن کل صف: همهٔ پیامک‌ها (در هر وضعیتی) حذف می‌شوند و دیگر بازنمی‌گردند.</span>
<span>خالی کردن صف: همهٔ موارد باقی‌مانده (در انتظار تأیید، تأییدشده، در حال پردازش و ناموفق)
حذف می‌شوند و دیگر بازنمی‌گردند. پیامک‌های ارسال‌شده دست‌نخورده می‌مانند (در گزارش ارسال).</span>
<form method="post" action="{% url 'admin_panel:sms_queue_empty' %}"
onsubmit="return confirm('کل صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
onsubmit="return confirm('صف پیامک خالی شود؟ این پیامک‌ها دیگر هرگز ارسال یا بازسازی نمی‌شوند.');">
{% csrf_token %}
<button class="button is-danger" type="submit">خالی کردن کل صف</button>
<button class="button is-danger" type="submit">خالی کردن صف</button>
</form>
</div>
@@ -119,11 +130,17 @@
<button class="button is-small is-warning" type="submit">ارسال مجدد</button>
</form>
{% endif %}
{% if item.status != 'sent' %}
<form method="post" action="{% url 'admin_panel:sms_queue_delete' item.id %}"
onclick="return confirm('این پیامک از صف حذف شود؟');">
{% csrf_token %}
<button class="button is-small is-danger" type="submit">حذف</button>
</form>
{% else %}
<a class="button is-small is-light"
href="{% url 'admin_panel:sms_report' %}?q={{ item.phone_number }}"
title="این پیامک ارسال شده است؛ سابقهٔ آن در گزارش ارسال است">در گزارش</a>
{% endif %}
</div>
</td>
</tr>
@@ -0,0 +1,189 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% block bodyPage %}
<div class="container mt-5" dir="rtl">
<h1 class="title is-4">گزارش ارسال پیامک</h1>
<p class="has-text-grey mb-3">
پیامک‌های <strong>تأییدشده و ارسال‌شده</strong> (موارد «در انتظار تأیید» و «لغو‌شده»
در این گزارش نمی‌آیند) به‌همراه «تعداد پیامکی که اپراتور محاسبه کرده»
(بر اساس کدگذاری GSM-7 برای انگلیسی/عدد و UCS-2 برای فارسی/یونیکد) — برای تحلیل هزینه.
</p>
{# --- کارت‌های خلاصه (روی کل نتیجهٔ فیلترشده) --- #}
<div class="columns is-multiline mb-2">
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">تعداد پیامک (رکورد)</p>
<p class="title is-5 mb-0">{{ total_records }}</p>
</div>
</div>
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">مجموع پیامک محاسبه‌شده</p>
<p class="title is-5 mb-0 has-text-danger">{{ total_parts }}</p>
</div>
</div>
<div class="column is-narrow">
<div class="box py-3 px-4 has-text-centered">
<p class="heading mb-0">GSM-7 / UCS-2</p>
<p class="title is-6 mb-0">{{ gsm7_parts }} / {{ ucs2_parts }}</p>
</div>
</div>
{% for code, label, count in status_stats %}
<div class="column is-narrow">
<a href="?status={{ code }}" class="box py-3 px-4 has-text-centered"
{% if status == code %}style="border:2px solid #3273dc"{% endif %}>
<p class="heading mb-0">{{ label }}</p>
<p class="title is-5 mb-0">{{ count }}</p>
</a>
</div>
{% endfor %}
</div>
{# --- فرم فیلتر --- #}
<form method="get" class="box">
<div class="columns is-multiline is-variable is-2">
<div class="column is-2">
<label class="label is-small">از تاریخ (شمسی)</label>
<input class="input" type="text" id="dateFromP" name="date_from"
value="{{ date_from }}" placeholder="1404/01/01" autocomplete="off">
</div>
<div class="column is-2">
<label class="label is-small">تا تاریخ (شمسی)</label>
<input class="input" type="text" id="dateToP" name="date_to"
value="{{ date_to }}" placeholder="1404/12/29" autocomplete="off">
</div>
<div class="column is-2">
<label class="label is-small">وضعیت ارسال</label>
<div class="select is-fullwidth">
<select name="status">
<option value="">همه</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">وضعیت تحویل</label>
<div class="select is-fullwidth">
<select name="delivery">
<option value="">همه</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">نوع پیامک</label>
<div class="select is-fullwidth">
<select name="sms_type">
<option value="">همه</option>
{% for value, label in type_choices %}
<option value="{{ value }}" {% if sms_type == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-small">جستجو (متن/شماره/نام مشتری)</label>
<input class="input" type="text" name="q" value="{{ query }}" placeholder="جستجو…">
</div>
</div>
<div class="buttons">
<button class="button is-link" type="submit">اعمال فیلتر</button>
{% if has_filter %}
<a class="button is-light" href="{% url 'admin_panel:sms_report' %}">حذف فیلترها</a>
{% endif %}
</div>
</form>
{# --- جدول --- #}
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
<thead>
<tr>
<th>شناسه</th>
<th style="min-width: 16rem;">متن دقیق پیامک</th>
<th style="min-width: 8rem;">شماره گیرنده</th>
<th>نوع پیامک</th>
<th style="min-width: 8rem;">مشتری</th>
<th>سوابق</th>
<th>تعداد پیامک</th>
<th>وضعیت ارسال</th>
<th>تحویل</th>
<th style="min-width: 8rem;">تاریخ ایجاد</th>
<th style="min-width: 8rem;">تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>{{ row.item.id }}</td>
<td class="smsrep-msg">{{ row.item.message }}</td>
<td dir="ltr">{{ row.item.phone_number }}</td>
<td>{{ row.item.get_sms_type_display|default:'دستی/سفارشی' }}</td>
<td>
{% if row.item.customer_id %}
<a href="{% url 'admin_panel:customer' row.item.customer_id %}">{{ row.item.customer.name_family }}</a>
{% else %}—{% endif %}
</td>
<td>
{% if row.item.customer_id %}
<a class="button is-small is-light"
href="{% url 'admin_panel:customerSmsHistory' row.item.customer_id %}"
title="مشاهدهٔ سوابق پیامک این مشتری">سوابق پیامک</a>
{% else %}—{% endif %}
</td>
<td class="has-text-centered">
<span class="tag {% if row.parts > 1 %}is-danger{% else %}is-success{% endif %} is-light"
title="{{ row.encoding }} · {{ row.length }} واحد">{{ row.parts }}</span>
</td>
<td>{{ row.item.get_status_display }}</td>
<td>{{ row.item.get_delivery_status_display|default:'—' }}</td>
<td>{{ row.item.created_at|toJalaliDT }}</td>
<td>{{ row.item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr><td colspan="11" class="has-text-centered">پیامکی با این شرایط یافت نشد.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page.has_other_pages %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
{% if page.has_previous %}
<a class="pagination-previous" href="?{{ base_query }}&page={{ page.previous_page_number }}">قبلی</a>
{% else %}<a class="pagination-previous" disabled>قبلی</a>{% endif %}
{% if page.has_next %}
<a class="pagination-next" href="?{{ base_query }}&page={{ page.next_page_number }}">بعدی</a>
{% else %}<a class="pagination-next" disabled>بعدی</a>{% endif %}
<ul class="pagination-list">
<li><span class="pagination-link">صفحهٔ {{ page.number }} از {{ page.paginator.num_pages }}</span></li>
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block cssEnd %}
<style>
/* متن پیامک با حفظ شکست خطوط و شکستن متن‌های طولانی/بدون‌فاصله */
.smsrep-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 26rem; line-height: 1.9; }
</style>
{% endblock %}
{% block jsEnd %}
<script>
(function () {
if (typeof kamaDatepicker !== 'function') { return; }
var opts = { buttonsColor: "red", forceFarsiDigits: true, sync: true };
['dateFromP', 'dateToP'].forEach(function (id) {
if (document.getElementById(id)) { kamaDatepicker(id, opts); }
});
})();
</script>
{% endblock %}
@@ -7,6 +7,13 @@
<div class="box">
<form method="post" action="{% url 'admin_panel:sms_settings' %}">
{% csrf_token %}
<h2 class="title is-5">اتصال به سامانه پیامک</h2>
<p class="help mb-3">
این بخش مشخص می‌کند پیامک‌ها <strong>از چه مسیری و با چه حساب کاربری</strong> به
اپراتور سپرده می‌شوند. تا وقتی «وضعیت ارسال» روشن نباشد، هیچ‌کدام از سرویس‌های
پس‌زمینه (worker) پیامکی نمی‌فرستند.
</p>
<div class="columns is-multiline">
<div class="field column is-4-desktop is-12-mobile">
<label class="label">سرویس‌دهنده</label>
@@ -19,6 +26,19 @@
</select>
</div>
</div>
<p class="help">
<strong>مسیر فنی ارسال را تعیین می‌کند</strong> و روی بقیهٔ فیلدها اثر دارد:
<br>• <span dir="ltr">0098SMS (لینکی HTTP)</span>: ساده‌ترین حالت؛ درخواست به
آدرس <span dir="ltr">api_url</span> زده می‌شود. <strong>شناسهٔ پیامک برنمی‌گردد</strong>،
پس استعلام «تحویل به گوشی» ممکن نیست.
<br>• <span dir="ltr">0098SMS (وب‌سرویس)</span>: ارسال SOAP به
<span dir="ltr">webservice_url</span>؛ <strong>شناسهٔ پیامک (smsid)</strong> برمی‌گردد و
می‌توان وضعیت واقعی تحویل را استعلام کرد.
<br>• <span dir="ltr">HTTP عمومی</span>: برای سرویس‌های دلخواه؛ با
<span dir="ltr">api_token</span> احراز هویت می‌شود.
<br>• <span dir="ltr">Console</span>: فقط برای تست/دیباگ؛ پیامک واقعی نمی‌رود و
متن در لاگ چاپ می‌شود.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
@@ -29,6 +49,11 @@
فعال باشد
</label>
</div>
<p class="help">
<strong>کلید اصلی خاموش/روشن ارسال.</strong> اگر تیک نخورد، سرویس worker هیچ
پیامکی نمی‌فرستد (ردیف‌ها در صف می‌مانند). اسکنر و صف‌کردن مستقل از این است،
پس می‌توانید پیامک‌ها را از قبل در صف بچینید و بعد ارسال را روشن کنید.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
@@ -36,6 +61,13 @@
<div class="control">
<input class="input" type="number" name="timeout_seconds" value="{{ config.timeout_seconds }}">
</div>
<p class="help">
<strong>حداکثر زمان انتظار worker برای پاسخ سرویس‌دهنده</strong> در هر تلاش.
اگر پاسخ دیرتر بیاید، آن تلاش «ناموفق» ثبت می‌شود و بر اساس سیاست تلاش مجدد
دوباره در نوبت قرار می‌گیرد. مقدار کم (مثل ۵) ممکن است در سرویس کند، ارسال‌های
سالم را هم ناموفق کند؛ مقدار زیاد (مثل ۱۲۰) در قطعی سرویس، worker را بیکار
نگه می‌دارد. پیش‌فرض ۳۰ ثانیه متعادل است.
</p>
</div>
<div class="field column is-12-mobile">
@@ -44,9 +76,14 @@
<input class="input" type="text" name="api_url" value="{{ config.api_url }}" dir="ltr">
</div>
<p class="help">
برای 0098SMS همین آدرس لینکی ارسال است
<strong>آدرس درخواست ارسال برای providerهای «لینکی HTTP» و «HTTP عمومی».</strong>
<br>• برای 0098SMS لینکی همین آدرس است
(<span dir="ltr">http://www.0098sms.com/sendsmslink.aspx</span>)؛ این سرویس
WSDL/SOAP ندارد و آدرس WSDL باعث خطای «Invalid XML content» می‌شود.
WSDL/SOAP ندارد و اگر آدرس WSDL بگذارید خطای «Invalid XML content» می‌گیرید.
<br>• برای provider وب‌سرویس، این فیلد استفاده <strong>نمی‌شود</strong> (آن یکی
<span dir="ltr">webservice_url</span> را می‌خواند).
<br>• اگر آدرس اشتباه یا در دسترس نباشد، پیامک‌ها «ناموفق» می‌شوند و در صف تلاش
مجدد می‌خورند.
</p>
</div>
@@ -56,8 +93,9 @@
<input class="input" type="text" name="webservice_url" value="{{ config.webservice_url }}" dir="ltr">
</div>
<p class="help">
ارسال با شناسهٔ پیامک (<span dir="ltr">smsid</span>) و استعلام تحویل به گوشی
از همین آدرس انجام می‌شود. پیش‌فرض:
<strong>فقط وقتی provider = «وب‌سرویس» معنا دارد.</strong> هم ارسال با شناسهٔ
پیامک (<span dir="ltr">smsid</span>) و هم استعلام تحویل به گوشی از همین آدرس
انجام می‌شود. با providerهای دیگر این فیلد نادیده گرفته می‌شود. پیش‌فرض:
<span dir="ltr">https://webservice.0098sms.com/service.asmx</span>
</p>
</div>
@@ -67,7 +105,11 @@
<div class="control">
<input class="input" type="text" name="panel_no" value="{{ config.panel_no }}" dir="ltr">
</div>
<p class="help">اگر خالی بماند از «شماره خط فرستنده» استفاده می‌شود.</p>
<p class="help">
<strong>شمارهٔ اختصاصی حساب شما در وب‌سرویس</strong> (فقط provider وب‌سرویس).
اگر خالی بماند، سیستم از «شماره خط فرستنده» استفاده می‌کند. مقدار اشتباه باعث
رد شدن ارسال با خطای احراز هویت می‌شود.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
@@ -75,6 +117,10 @@
<div class="control">
<input class="input" type="text" name="username" value="{{ config.username }}" dir="ltr">
</div>
<p class="help">
<strong>نام کاربری حساب سامانهٔ پیامک</strong> (در همهٔ providerها به‌جز Console
لازم است). اگر اشتباه باشد، ارسال‌ها رد می‌شوند و در «خطا» صف ثبت می‌گردد.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
@@ -82,6 +128,11 @@
<div class="control">
<input class="input" type="password" name="password" placeholder="{% if config.password %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
<p class="help">
<strong>رمز حساب سامانهٔ پیامک.</strong> برای امنیت، مقدار ذخیره‌شده بازخوانی
نمی‌شود؛ این کادر را <strong>خالی بگذارید</strong> تا رمز قبلی بماند و فقط وقتی
رمز عوض شده، مقدار جدید را وارد کنید.
</p>
</div>
<div class="field column is-4-desktop is-12-mobile">
@@ -89,6 +140,12 @@
<div class="control">
<input class="input" type="text" name="sender" value="{{ config.sender }}" dir="ltr">
</div>
<p class="help">
<strong>شماره یا عنوان خطی که پیامک با آن به دست مشتری می‌رسد.</strong> باید
جزو خطوط مجاز حساب شما باشد؛ در غیر این صورت اپراتور ارسال را رد می‌کند.
همچنین در provider وب‌سرویس، اگر «شمارهٔ پنل» خالی باشد همین مقدار جای آن
استفاده می‌شود.
</p>
</div>
<div class="field column is-12-mobile">
@@ -96,15 +153,23 @@
<div class="control">
<input class="input" type="password" name="api_token" placeholder="{% if config.api_token %}(بدون تغییر)‌{% endif %}" dir="ltr">
</div>
<p class="help">
<strong>کلید/توکن احراز هویت providerهای «HTTP عمومی».</strong> برای 0098SMS
لازم نیست. مثل رمز، خالی گذاشتن آن یعنی «بدون تغییر».
</p>
</div>
</div>
<hr>
<h2 class="title is-5">کنترل جریان ارسال</h2>
<p class="help mb-3">
برای جلوگیری از سیل پیامک: تنها مواردی ارسال می‌شوند که پنجرهٔ یادآوری‌شان از
«خط شروع» به بعد باز شود. موارد قدیمی‌تر تنها با روشن‌کردن «اجازهٔ موارد قدیمی»
و در سقف تعیین‌شده ارسال می‌شوند. بین هر پیامک هم فاصلهٔ زمانی رعایت می‌شود.
این بخش تعیین می‌کند اسکنر دوره‌ای <strong>چه چیزی و چقدر</strong> در هر اجرا در صف
بگذارد. اسکنر هر چند دقیقه یک‌بار اجرا می‌شود و بر اساس «سررسیدها» تصمیم می‌گیرد
کدام مشتری باید یادآوری بگیرد. منطق بودجه به این شکل است:
<strong>سقف هر اجرا</strong> ← تعیین می‌کند کلاً چند مورد صف شود؛
<strong>خط شروع</strong> ← موارد قدیمی‌تر از آن را کنار می‌گذارد؛
<strong>اجازهٔ موارد قدیمی + سقف آن</strong> ← تصمیم می‌گیرد از آن موارد کنارگذاشته‌شده
چند تا (اگر لازم بود) ارسال شود.
</p>
<div class="columns is-multiline">
@@ -116,9 +181,15 @@
placeholder="مثلاً 1403/07/18 — خالی = همین حالا" dir="ltr">
</div>
<p class="help">
فقط یادآوری‌هایی صف می‌شوند که «پنجرهٔ» آن‌ها از این تاریخ به بعد باز شده باشد.
مشتریان قدیمی‌تری که پنجره‌شان پیش از این تاریخ باز شده، وارد صف نمی‌شوند.
قالب: <span dir="ltr">YYYY/MM/DD</span> (شمسی) — خالی = همین حالا.
<strong>خط جداکنندهٔ «موارد جدید» از «موارد قدیمی».</strong>
هر مورد (چک، نسیه، بازدید) یک «لحظهٔ باز شدن پنجرهٔ یادآوری» دارد. اگر این
لحظه <strong>از این تاریخ به بعد</strong> باشد، مورد «جدید» است و آزادانه صف
می‌شود؛ اگر <strong>قبل از</strong> آن باشد، «قدیمی» (backlog) حساب می‌شود.
<br>• هدف اصلی: در نخستین راه‌اندازی، هزاران مشتری قدیمیِ واجد شرط یک‌باره پیامک
نگیرند.
<br>• قالب: <span dir="ltr">YYYY/MM/DD</span> شمسی — خالی = همین حالا.
<br>• عوض‌کردن آن به گذشته، مشتریان قدیمی‌تر را «جدید» می‌کند و ممکن است سیل پیامک
بسازد؛ با احتیاط تغییر دهید.
</p>
</div>
@@ -127,6 +198,19 @@
<div class="control">
<input class="input" type="number" min="0" name="max_per_run" value="{{ config.max_per_run }}">
</div>
<p class="help">
<strong>سقف کل صف‌کردن در یک اجرا — به‌ازای هر اسکنر جداگانه.</strong>
<br>• «هر اسکنر» یعنی این عدد برای هر نوع یادآوری مستقل اعمال می‌شود
(تأمین موجودی چک، سررسید نسیه، بازدید دورهای)؛ پس اگر ۱۰۰ باشد و هر سه اسکنر
واجد شرط باشند، در یک اجرا حداکثر ۱۰۰ + ۱۰۰ + ۱۰۰ = ۳۰۰ ردیف صف می‌شود.
<br>• شمارش از مجموع «جدیدها + موارد قدیمی» است، نه فقط جدیدها.
<br>• <strong>۰ = بی‌نهایت</strong> (هیچ سقفی اعمال نمی‌شود).
<br>• چون اسکنر دوره‌ای اجرا می‌شود، سقف باعث «دسته‌دسته» شدن ارسال‌های انبوه
می‌شود (بقیه در اجراهای بعدی می‌آیند) و از ریختن یک‌بارهٔ صدها پیامک روی ارسال
جلوگیری می‌کند.
<br>• نکته: این سقف‌ها روی «صف‌کردن» اثر دارند، نه سرعت ارسال واقعی؛ سرعت ارسال
به «فاصلهٔ بین هر پیامک» بستگی دارد.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
@@ -134,6 +218,11 @@
<div class="control">
<input class="input" type="number" min="0" name="send_interval_seconds" value="{{ config.send_interval_seconds }}">
</div>
<p class="help">
<strong>مکث سرویس worker پس از هر ارسال</strong> تا اپراتور زیر بار زیاد نرود.
مثال: با مقدار ۳، ارسال ۱۰۰ پیامک حدود ۵ دقیقه طول می‌کشد. مقدار ۰ یعنی بدون
مکث (سریع ولی پرخطر — ممکن است سرویس‌دهنده محدودتان کند).
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
@@ -144,6 +233,12 @@
فعال باشد
</label>
</div>
<p class="help">
<strong>دربِ موارد «قدیمی‌تر از تاریخ شروع دوره».</strong> به‌صورت پیش‌فرض بسته
است. تا وقتی بسته باشد، موارد قدیمی <strong>هرگز</strong> صف نمی‌شوند (حتی اگر
سقف باقی باشد). اگر لازم بود این موارد هم پردازش شوند، آن را روشن کنید و مقدار
«حداکثر موارد قدیمی در هر اجرا» را تعیین کنید.
</p>
</div>
<div class="field column is-3-desktop is-6-mobile">
@@ -151,7 +246,15 @@
<div class="control">
<input class="input" type="number" min="0" name="backlog_max" value="{{ config.backlog_max }}">
</div>
<p class="help">صفر = هیچ مورد قدیمی ارسال نمی‌شود.</p>
<p class="help">
<strong>سقف ارسال موارد قدیمی در هر اجرا، به‌ازای هر اسکنر.</strong>
<br>• فقط وقتی «اجازهٔ ارسال موارد قدیمی» روشن باشد اثر دارد.
<br>• صفر = هیچ مورد قدیمی ارسال نمی‌شود (حتی با روشن‌بودن کلید).
<br>• این سقف <strong>در کنار</strong> «حداکثر پیامک هر اسکنر» اعمال می‌شود؛ پس
ممکن است ابتدا سقف کل (max_per_run) پر شود.
<br>• برای پاک‌کردن تدریجی انبار قدیمی، عدد کوچکی مثل ۵۰ بگذارید تا در هر اجرا
دسته‌ای ارسال شود.
</p>
</div>
</div>
+2
View File
@@ -81,6 +81,7 @@ urlpatterns = [
path('pardakhtNesiye/<int:cs_id>', views.pardakhtNesiye, name='pardakhtNesiye'),
# === مدیریت پیامک ===
path('customer/<int:c_id>/sms/', views.customer_sms_history, name='customerSmsHistory'),
path('sms/settings', views.sms_settings, name='sms_settings'),
path('sms/templates', views.sms_templates, name='sms_templates'),
path('sms/template/<int:t_id>/edit', views.sms_template_edit, name='sms_template_edit'),
@@ -88,6 +89,7 @@ urlpatterns = [
path('sms/customer/<int:c_id>/primary', views.sms_primary_toggle, name='sms_primary_toggle'),
path('sms/customer/<int:c_id>/send', views.sms_send_custom, name='sms_send_custom'),
path('sms/queue', views.sms_queue, name='sms_queue'),
path('sms/report', views.sms_report, name='sms_report'),
path('sms/queue/bulk', views.sms_queue_bulk, name='sms_queue_bulk'),
path('sms/queue/empty', views.sms_queue_empty, name='sms_queue_empty'),
path('sms/queue/<int:q_id>/approve', views.sms_queue_approve, name='sms_queue_approve'),
+5
View File
@@ -7,6 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
@login_required(login_url='/panel/p_login/')
@@ -91,6 +92,10 @@ def customerNewAction(request ,c_id):
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == 'bazdid':
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
+10 -16
View File
@@ -11,11 +11,10 @@ from django.contrib.auth.decorators import login_required, permission_required
from persiantools.jdatetime import JalaliDate
from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor,
FactorIteam, Article, Sarresid, Action, CacheTable, SMSType)
from ..sms.enqueue import enqueue_sms
from ..sms.phones import (customer_phone_entries, normalize_primary_values,
FactorIteam, Article, Sarresid, Action, CacheTable)
from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.recipients import resolve_recipients
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -122,15 +121,10 @@ def customerNew2(request ,c_id):
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
sms_toggles = customer_toggle_map(customer)
# همهٔ شماره‌ها از هر منبعی که ذخیره شده‌اند (PhoneNumber و phone1/2/3 قدیمی).
sms_phone_entries = customer_phone_entries(customer)
sms_effective = {n for n in resolve_recipients(customer)}
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
'sms_toggles': sms_toggles, 'sms_phone_entries': sms_phone_entries,
'sms_effective': sms_effective,
'sms_recipients': sorted(sms_effective),
'sms_toggles': sms_toggles,
}
return render(request, 'admin_panel/customerNew2.html', data)
@@ -271,12 +265,7 @@ def customersAdd(request):
)
# پیامک خوش‌آمدگویی (در صورت فعال بودن قالب و تنظیم مشتری)
enqueue_sms(
customer=newCs, sms_type=SMSType.WELCOME,
context={'name': newCs.name_family},
dedup_key=f'welcome:{newCs.id}',
created_by=request.user,
)
notify_welcome(newCs, created_by=request.user)
return redirect('/panel/customers/')
@@ -494,4 +483,9 @@ def customerHesabdariAdd(request ,c_id):
newArticl.user = user_op
newArticl.nahvePardakht = nahvePardakhat
newArticl.save()
# سند بستانکار (credit>0 و debit=0) یعنی مشتری پول پرداخت کرده ⇒ پیامک تشکر.
if type == 'credit':
notify_payment_thanks(newArticl.customer, newArticl.credit,
article_id=newArticl.id, method=nahvePardakhat,
created_by=user_op)
return redirect('/panel/customer/'+str(c_id)+'/hesabdari')
+15 -2
View File
@@ -12,6 +12,7 @@ from persiantools import digits
from ..models import (Customer, StoreRoom, Factor, FactorIteam, Product,
ProductInStore, Article, Sarresid)
from ..sms.triggers import notify_factor_survey, notify_payment_thanks
from ._helpers import logger, _safe_int, _parse_jalali_date, _parse_timestamp, InventoryException
@@ -196,6 +197,8 @@ def newFactor2(request, c_id):
if not str(item.get('garanty_Esh', '')).strip()
)
new_factor = None
payment_articles = []
try:
with transaction.atomic():
@@ -231,7 +234,7 @@ def newFactor2(request, c_id):
if key in payment_map:
nahve_pardakht = payment_map[key]
Article.objects.create(
payment_articles.append((Article.objects.create(
debit=0,
credit=amount,
customer=customer_obj,
@@ -240,7 +243,7 @@ def newFactor2(request, c_id):
nahvePardakht=nahve_pardakht,
payStatus="true",
user=user_op
)
), nahve_pardakht))
elif key in ['l_nesiye', 'l_checkPrice']:
is_nesiye = (key == 'l_nesiye')
@@ -359,6 +362,16 @@ def newFactor2(request, c_id):
logger.error(f"Error creating factor for customer {c_id}: {str(e)}", exc_info=True)
return HttpResponse("خطایی غیرمنتظره در سرور رخ داد. اطلاعات ذخیره نشد.", status=500)
# تریگرهای پیامک پس از تأیید تراکنش (خارج از atomic تا در صورت rollback ساخته نشوند).
if new_factor is not None:
# نظرسنجی بی‌واسطه پس از ثبت فاکتور.
notify_factor_survey(customer_obj, new_factor, created_by=user_op)
# تشکر برای هر پرداخت نقدی/کارتیِ ثبت‌شده روی همین فاکتور (تخفیف مستثناست).
for art, method in payment_articles:
notify_payment_thanks(customer_obj, art.credit,
article_id=art.id, method=method,
created_by=user_op)
return redirect(f'/panel/customer/{c_id}/factors/')
+11
View File
@@ -16,6 +16,7 @@ from persiantools.jdatetime import JalaliDate
from ..models import (Article, Sarresid, Customer, Factor, ProductInStore,
StoreRoom, Action)
from ..sms.triggers import notify_payment_thanks
from ._helpers import logger, _safe_int
@@ -713,6 +714,11 @@ def passCheck(request, articl_id):
sarresidINC.desc = desc
sarresidINC.save()
# سند وصول (credit>0 و debit=0) یعنی مشتری پول داده ⇒ پیامک تشکر از پرداخت.
notify_payment_thanks(articleHesabDari2.customer, articleHesabDari2.credit,
article_id=articleHesabDari2.id,
method=articleHesabDari2.nahvePardakht, created_by=user_op)
return HttpResponse("ok")
#return redirect(request.META.get('HTTP_REFERER'))
@@ -916,6 +922,11 @@ def pardakhtNesiye(request, cs_id):
articleHesabDariObj.user = user_op
articleHesabDariObj.save()
# پرداخت نسیه (بستانکار) ⇒ پیامک تشکر از پرداخت (تخفیف مستثناست).
notify_payment_thanks(customerObj, articleHesabDariObj.credit,
article_id=articleHesabDariObj.id,
method=nahvePardakht, created_by=user_op)
return redirect(request.META.get('HTTP_REFERER'))
except:
+217 -30
View File
@@ -1,11 +1,11 @@
"""ویوهای مدیریت پیامک در پنل (نیازمند پرمیشن ``admin_panel.ManageSMS``)."""
import re
from datetime import datetime, time
from datetime import datetime, time, timedelta
from urllib.parse import urlencode
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Count
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
@@ -13,12 +13,34 @@ from django.utils import timezone
from ..models import (Customer, SMSDedup, SMSProviderConfig, SMSQueue,
SMSTemplate, SMSType)
from ..sms.enqueue import enqueue_sms
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.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
# صفحات «تنظیمات سامانه» و «قالب‌ها» یک سطح دسترسی جداگانه دارند تا مدیر بتواند
# فقط به کاربران موردنظرش اجازهٔ دیدن/ویرایش بدهد (نه هر کاربر دارای ManageSMS).
_settings_required = permission_required(
"admin_panel.ManageSMSSettings", raise_exception=True,
)
# گزارش هزینه فقط روی پیامک‌هایی است که از مرحلهٔ «تأیید مدیر» گذشته و واقعاً به
# سامانه/اپراتور سپرده شده‌اند. موارد «در انتظار تأیید» (هنوز ارسال نشده) و
# «لغو‌شده» (هرگز ارسال نمی‌شود) نباید در تحلیل هزینه بیایند.
SENT_STATUSES = [
SMSQueue.Status.APPROVED,
SMSQueue.Status.PROCESSING,
SMSQueue.Status.SENT,
SMSQueue.Status.FAILED,
]
# وضعیت‌های «پایانی» که دیگر کاری روی آن‌ها لازم نیست. صفحهٔ «صف ارسال» فقط مواردِ
# نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ
# تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند.
DONE_STATUSES = [SMSQueue.Status.SENT]
def _notice(url, code, **extra):
@@ -47,14 +69,13 @@ TEMPLATE_VARIABLES = [
# فقط برای نمایش: آیکن و توضیح کوتاه «چه زمانی ارسال می‌شود» هر نوع (لایهٔ UI، نه دامنه).
SMS_TYPE_UI = {
SMSType.WELCOME: {"icon": "👋", "when": "بیدرنگ پس از ثبت مشتری"},
SMSType.CHEQUE_DUE: {"icon": "🧾", "when": "پیش از سررسید چک"},
SMSType.WELCOME: {"icon": "👋", "when": "بی‌درنگ پس از ثبت مشتری"},
SMSType.SURVEY: {"icon": "⭐", "when": "بی‌درنگ پس از ثبت فاکتور"},
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پس از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
SMSType.CHEQUE_REGISTRATION: {"icon": "⏳", "when": "تا ثبت‌شدن اطلاعات چک"},
SMSType.DEBT_DUE: {"icon": "💰", "when": "روز سررسید وصول مطالبات"},
SMSType.SURVEY: {"icon": "⭐", "when": "پس از صدور فاکتور"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید"},
SMSType.CUSTOM: {"icon": "✍️", "when": "ارسال دستی از صفحهٔ مشتری"},
}
# نمونهٔ داده برای «پیش‌نمایش زندهٔ» متن در پنل (هیچ پیامکی با این مقادیر ارسال نمی‌شود).
@@ -70,7 +91,7 @@ SAMPLE_CONTEXT = {
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_settings(request):
config = SMSProviderConfig.load()
if request.method == "POST":
@@ -82,7 +103,7 @@ def sms_settings(request):
config.test_message = test_text
config.save(update_fields=["test_message"])
enqueue_sms(
customer=None, sms_type=SMSType.CUSTOM,
customer=None, sms_type="",
message=test_text or config.test_message,
recipients=resolve_recipients_test(phone), created_by=request.user,
approved=True, # ارسال دستیِ مدیر؛ نیاز به بازبینی صف ندارد.
@@ -192,7 +213,7 @@ def _sample_context():
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_templates(request):
raw_sample, text_sample = _sample_context()
rows = []
@@ -236,7 +257,7 @@ def _templates_redirect(anchor="", notice="", **extra):
@login_required(login_url='/panel/p_login/')
@_manage_required
@_settings_required
def sms_template_edit(request, t_id):
"""ویرایش یک قالب. فیلد زمانی بر اساس نوع پیامک انتخاب و اعتبارسنجی می‌شود.
@@ -321,22 +342,17 @@ def sms_primary_toggle(request, c_id):
def sms_send_custom(request, c_id):
"""ارسال پیامک با متن سفارشی برای همین مشتری.
اگر کادر متن خالی بماند، متن قالب «پیام سفارشی» استفاده می‌شود. متغیرهایی مثل
متن باید توسط مدیر نوشته شود (قالب پیش‌فرضی وجود ندارد)؛ متغیرهایی مثل
``{name}`` در متن واردشده هم رندر می‌شوند.
"""
customer = get_object_or_404(Customer, id=c_id)
if request.method == "POST":
text = (request.POST.get("text", "") or "").strip()
if not text:
template = SMSTemplate.objects.filter(
sms_type=SMSType.CUSTOM, is_enabled=True,
).first()
text = (template.text if template else "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
return _notice(_customer_url(c_id), "custom_empty")
created = enqueue_sms(
customer=customer, sms_type=SMSType.CUSTOM, message=rendered,
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
@@ -348,9 +364,19 @@ def sms_send_custom(request, c_id):
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue(request):
"""صف ارسال: فقط مواردی که هنوز «کارِ باقی‌مانده» دارند.
پیامک‌های ارسال‌شده (``sent``) از این فهرست بیرون می‌مانند چون کارشان تمام است و
سابقه‌شان در «گزارش ارسال» دیده می‌شود؛ ماندن‌شان در صف فقط باعث می‌شد دکمهٔ
«حذف» روی تاریخچه ظاهر شود. بقیهٔ وضعیت‌ها (در انتظار تأیید، تأییدشده، در حال
پردازش، ناموفق) در صف می‌مانند چون هنوز نیاز به اقدام دارند.
"""
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = SMSQueue.objects.all().select_related("customer").order_by("-created_at")
qs = (
SMSQueue.objects.exclude(status__in=DONE_STATUSES)
.select_related("customer").order_by("-created_at")
)
if status:
qs = qs.filter(status=status)
if delivery:
@@ -364,24 +390,177 @@ def sms_queue(request):
counts = dict(
SMSQueue.objects.order_by().values_list("status").annotate(n=Count("id"))
)
# «کل صف» = موارد باقی‌مانده (بدون ارسال‌شده‌ها).
queue_total = sum(n for code, n in counts.items() if code not in DONE_STATUSES)
data = {
"queue": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
# گزینه‌های فیلتر وضعیت: فقط وضعیت‌های درون صف (ارسال‌شده حذف می‌شود).
"status_choices": [
(code, label) for code, label in SMSQueue.Status.choices
if code not in DONE_STATUSES
],
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"pending_count": counts.get(SMSQueue.Status.PENDING, 0),
"total_count": sum(counts.values()),
"total_count": queue_total,
# تعداد ارسال‌شده‌ها جداگانه نمایش داده می‌شود (برای رفتن به گزارش).
"sent_count": counts.get(SMSQueue.Status.SENT, 0),
"filtered_count": paginator.count,
# (کد وضعیت، برچسب، تعداد) — به ترتیب تعریف Status.
# (کد وضعیت، برچسب، تعداد) — فقط وضعیت‌های درون صف.
"status_stats": [
(code, label, counts.get(code, 0))
for code, label in SMSQueue.Status.choices
if code not in DONE_STATUSES
],
}
return render(request, "admin_panel/sms_queue.html", data)
@login_required(login_url='/panel/p_login/')
@_manage_required
def customer_sms_history(request, c_id):
"""سوابق پیامک‌های یک مشتری روی صفحهٔ خود مشتری.
همهٔ پیامک‌هایی که برای شماره‌های این مشتری در صف قرار گرفته/ارسال شده‌اند
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده می‌شوند و
فهرست صفحه‌بندی می‌شود. با ``?status=`` و ``?delivery=`` می‌توان فیلتر کرد.
"""
customer = get_object_or_404(Customer, id=c_id)
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
qs = (
SMSQueue.objects.filter(customer=customer).order_by("-created_at")
)
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
data = {
"customer": customer,
"items": page,
"status": status,
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"total_count": SMSQueue.objects.filter(customer=customer).count(),
"filtered_count": paginator.count,
}
return render(request, "admin_panel/customerSmsHistory.html", data)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_report(request):
"""گزارش پیامک‌های ارسال‌شده برای تحلیل هزینه.
امکان فیلتر بر اساس **بازهٔ تاریخ شمسی** (از/تا)، وضعیت ارسال، وضعیت تحویل و
نوع پیامک، به‌همراه جستجوی متنی روی متن پیامک/شماره/نام مشتری.
برای هر ردیف، «تعداد پیامکی که اپراتور محاسبه کرده» (parts) بر اساس کدگذاری
واقعی متن (GSM-7 برای انگلیسی/عدد، UCS-2 برای فارسی/یونیکد) محاسبه و نمایش
داده می‌شود؛ جمع کل بخش‌ها هم برای برآورد هزینه گزارش می‌شود.
"""
date_from_raw = (request.GET.get("date_from") or "").strip()
date_to_raw = (request.GET.get("date_to") or "").strip()
status = request.GET.get("status", "")
delivery = request.GET.get("delivery", "")
sms_type = request.GET.get("sms_type", "")
query = (request.GET.get("q") or "").strip()
qs = (
SMSQueue.objects.select_related("customer")
.filter(status__in=SENT_STATUSES)
.order_by("-created_at")
)
# بازهٔ تاریخ شمسی روی «زمان ایجاد» (ثبت در سیستم) اعمال می‌شود. بازهٔ «تا» تا
# پایان همان روز در نظر گرفته می‌شود تا پیامک‌های همان روز هم بیایند.
parsed_from = parse_jalali_date(date_from_raw) if date_from_raw else None
parsed_to = parse_jalali_date(date_to_raw) if date_to_raw else None
if parsed_from is not None:
qs = qs.filter(created_at__gte=parsed_from)
if parsed_to is not None:
qs = qs.filter(created_at__lt=parsed_to + timedelta(days=1))
if status:
qs = qs.filter(status=status)
if delivery:
qs = qs.filter(delivery_status=delivery)
if sms_type:
qs = qs.filter(sms_type=sms_type)
if query:
qs = qs.filter(
Q(message__icontains=query)
| Q(phone_number__icontains=query)
| Q(customer__name_family__icontains=query)
)
# --- آمار کلی روی کل نتیجهٔ فیلترشده (نه فقط صفحهٔ جاری) ---
status_counts = dict(
qs.order_by().values_list("status").annotate(n=Count("id"))
)
total_records = sum(status_counts.values())
# جمع بخش‌ها (parts) باید در پایتون حساب شود چون به کدگذاری هر متن بستگی دارد.
total_parts = 0
gsm7_parts = ucs2_parts = 0
for message in qs.values_list("message", flat=True).iterator(chunk_size=500):
info = describe(message)
total_parts += info["parts"]
if info["encoding"] == "gsm7":
gsm7_parts += info["parts"]
else:
ucs2_parts += info["parts"]
paginator = Paginator(qs, 50)
page = paginator.get_page(request.GET.get("page"))
# برای هر ردیف صفحه، محاسبهٔ کدگذاری/طول/بخش (نمایشی).
rows = []
for item in page:
info = describe(item.message)
rows.append({"item": item, "parts": info["parts"],
"encoding": info["encoding_label"], "length": info["length"]})
# برای لینک صفحه‌بندی که همهٔ فیلترها را حفظ کند (بدون خودِ page).
params = request.GET.copy()
params.pop("page", None)
base_query = params.urlencode()
data = {
"rows": rows,
"page": page,
"status": status,
"delivery": delivery,
"sms_type": sms_type,
"query": query,
"date_from": date_from_raw,
"date_to": date_to_raw,
"status_choices": [
(code, label) for code, label in SMSQueue.Status.choices
if code in SENT_STATUSES
],
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"type_choices": SMSType.choices,
"total_records": total_records,
"total_parts": total_parts,
"gsm7_parts": gsm7_parts,
"ucs2_parts": ucs2_parts,
"encoding_labels": ENCODING_LABELS,
"status_stats": [
(code, label, status_counts.get(code, 0))
for code, label in SMSQueue.Status.choices
if code in SENT_STATUSES
],
"filtered": paginator.count,
"base_query": base_query,
"has_filter": any([date_from_raw, date_to_raw, status, delivery, sms_type, query]),
}
return render(request, "admin_panel/sms_report.html", data)
def _approve(queryset):
"""پیامک‌های «در انتظار تأیید» را تأیید و آمادهٔ ارسال می‌کند."""
return queryset.filter(status=SMSQueue.Status.PENDING).update(
@@ -403,9 +582,15 @@ def sms_queue_approve(request, q_id):
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_delete(request, q_id):
"""حذف یک پیامک از صف (مثلاً پس از بازبینی و رد آن)."""
"""حذف یک پیامک از صف (مثلاً پس از بازبینی و رد آن).
پیامکِ ارسال‌شده حذف نمی‌شود؛ سابقهٔ آن در «گزارش ارسال» می‌ماند و حذفش یعنی
پاک‌کردنِ تاریخچه. برای چنین موردی فقط پیام هشدار برمی‌گردد.
"""
item = get_object_or_404(SMSQueue, id=q_id)
if request.method == "POST":
if item.status in DONE_STATUSES:
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@@ -427,6 +612,8 @@ def sms_queue_bulk(request):
count = _approve(selected)
code, extra = "bulk_approved", {"count": count}
elif action == "delete_selected":
# تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود.
selected = selected.exclude(status__in=DONE_STATUSES)
count = selected.count()
selected.delete()
code, extra = "bulk_deleted", {"count": count}
@@ -439,14 +626,14 @@ def sms_queue_bulk(request):
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
"""خالی کردن کل صف — هر وضعیتی، بدون بازگشت.
"""خالی کردن صف — فقط موارد باقی‌مانده (بدون بازگشت).
پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود تا اسکنر
در اجرای بعدی همان پیامک‌ها را از نو نسازد. رکوردهایی که کلید ندارند (مثلاً
پیامک دستی) کلید مشتق از خودشان را می‌گیرند.
پیامک‌های ارسال‌شده جزو صف نیستند و حذف نمی‌شوند؛ تاریخچه‌شان در «گزارش ارسال»
لازم است. پیش از حذف، کلید یکتاسازیِ هر رکورد در دفتر ``SMSDedup`` تضمین می‌شود
تا اسکنر در اجرای بعدی همان پیامک‌ها را از نو نسازد.
"""
if request.method == "POST":
rows = SMSQueue.objects.all()
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
SMSDedup.objects.bulk_create(
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,