fix sms
This commit is contained in:
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),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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} پیامک از صف حذف شد."),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز میشود و تا سررسید ادامه دارد.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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/')
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user