some improve
This commit is contained in:
1 parent
fa73a734a0
commit
2e5d0bcdd3
17 files changed
+1512
-226
No files matched your search
@@ -0,0 +1,113 @@
|
||||
"""افزودن نوع پیامک ``install_created`` («ثبت نصب — اقدام از نوع نصب»).
|
||||
|
||||
**این مهاجرت هیچ دادهٔ موجودی را تغییر نمیدهد.** سه کار انجام میشود:
|
||||
|
||||
۱. ``choices`` سه فیلد ``sms_type`` (قالب، تنظیم مشتری، صف) به ۸ نوع گسترش
|
||||
مییابد تا مقدار جدید در پنل/اعتبارسنجی معتبر باشد. ``AlterField`` فقط
|
||||
متادیتای ستون را عوض میکند و به ارزش ردیفها دست نمیزند.
|
||||
۲. **فقط** یک قالب تازه برای نوع جدید ساخته میشود (``get_or_create``): اگر از
|
||||
قبل وجود داشته باشد، هیچ فیلدی از آن بازنویسی نمیشود.
|
||||
* ``is_enabled=True`` ⇒ از پنل قالبها دیده/ویرایش شود.
|
||||
* ``auto_send=False`` (صریح) ⇒ در صف «در انتظار تأیید» میماند تا مدیر خودش
|
||||
تأیید/ارسال کند؛ هیچ پیامکی ناخواسته ارسال نمیشود.
|
||||
۳. قالبهای هفت نوع قبلی حتی لمس هم نمیشوند: در این فایل هیچ ``update``/``save``
|
||||
روی آنها وجود ندارد.
|
||||
|
||||
متغیرهای متنی همان ``{name}`` و ``{date}`` هستند؛ نیازی به تغییر ساختار نیست.
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
SMS_TYPE_CHOICES = [
|
||||
("welcome", "خوشآمدگویی (ایجاد مشتری)"),
|
||||
("survey", "نظرسنجی پس از ثبت فاکتور"),
|
||||
("cheque_registration", "یادآوری تأمین موجودی چک"),
|
||||
("visit", "یادآوری بازدید خدمات دستگاه"),
|
||||
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
|
||||
("install_created", "ثبت نصب (اقدام از نوع نصب)"),
|
||||
("payment_thanks", "تشکر از پرداخت"),
|
||||
("nesiye_due", "یادآوری سررسید نسیه"),
|
||||
]
|
||||
|
||||
NEW_SMS_TYPE = "install_created"
|
||||
|
||||
# قالب پیشفرض نوع جدید. متن مشابه «ثبت بازدید» است تا لحن پیامها یکدست بماند.
|
||||
INSTALL_TEMPLATE = {
|
||||
"title": "ثبت نصب",
|
||||
"text": "{name} عزیز، نصب شما برای تاریخ {date} ثبت شد. "
|
||||
"از همکاری شما سپاسگزاریم.",
|
||||
"is_enabled": True,
|
||||
# خاموش: پیامک در «در انتظار تأیید» میماند (رفتار پیشفرض امن پروژه).
|
||||
"auto_send": False,
|
||||
"offset_days": 0,
|
||||
"repeat_interval_days": None,
|
||||
"repeat_interval_months": None,
|
||||
}
|
||||
|
||||
|
||||
def seed_install_template(apps, schema_editor):
|
||||
"""ایجاد قالب «ثبت نصب» اگر وجود ندارد (اجرای دوباره بیخطر است).
|
||||
|
||||
``get_or_create`` عمداً بدون هیچ ``update`` است: قالبهای موجود (هر هفت نوع
|
||||
قبلی) و حتی خودِ ``install_created`` اگر از قبل باشد، دستنخورده میمانند.
|
||||
"""
|
||||
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
|
||||
SMSTemplate.objects.get_or_create(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
defaults={
|
||||
"title": INSTALL_TEMPLATE["title"],
|
||||
"text": INSTALL_TEMPLATE["text"],
|
||||
"is_enabled": INSTALL_TEMPLATE["is_enabled"],
|
||||
"auto_send": INSTALL_TEMPLATE["auto_send"],
|
||||
"offset_days": INSTALL_TEMPLATE["offset_days"],
|
||||
"repeat_interval_days": INSTALL_TEMPLATE["repeat_interval_days"],
|
||||
"repeat_interval_months": INSTALL_TEMPLATE["repeat_interval_months"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def drop_install_template(apps, schema_editor):
|
||||
"""بازگشت: فقط ردیفهای همان نوع جدید حذف میشوند.
|
||||
|
||||
سایر قالبها و تنظیمهای مشتری دست نمیخورند.
|
||||
"""
|
||||
apps.get_model("admin_panel", "SMSTemplate").objects.filter(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
).delete()
|
||||
apps.get_model("admin_panel", "CustomerSMSSetting").objects.filter(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("admin_panel", "0080_action_soft_delete"),
|
||||
]
|
||||
|
||||
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(seed_install_template, drop_install_template),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from django.db import models
|
||||
|
||||
|
||||
class SMSType(models.TextChoices):
|
||||
"""انواع پیامکهای سیستم (دقیقاً ۷ نوع، به ترتیب چرخهٔ کاری).
|
||||
"""انواع پیامکهای سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
|
||||
|
||||
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
|
||||
اسکنر دورهای در صف قرار میگیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
|
||||
@@ -23,6 +23,7 @@ class SMSType(models.TextChoices):
|
||||
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
|
||||
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
|
||||
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
|
||||
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
|
||||
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
|
||||
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
|
||||
|
||||
|
||||
@@ -261,8 +261,9 @@ def scan_visit(today, *, now=None):
|
||||
return count
|
||||
|
||||
|
||||
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوشآمدگویی، نظرسنجی، ثبت بازدید، تشکر از
|
||||
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف میشوند، نه اینجا.
|
||||
# فقط انواعی که «موعد» دارند و دورهای اسکن میشوند؛ انواع رویدادی (خوشآمدگویی،
|
||||
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
|
||||
# توسط ویوها صف میشوند، نه اینجا.
|
||||
SCANNERS = [
|
||||
("cheque_registration", scan_cheque_funding),
|
||||
("nesiye_due", scan_nesiye_due),
|
||||
|
||||
@@ -46,6 +46,11 @@ TIMING = {
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع بازدید» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.INSTALL_CREATED: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع نصب» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.PAYMENT_THANKS: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* :func:`notify_welcome` — ایجاد مشتری.
|
||||
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
|
||||
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
|
||||
* :func:`notify_install_scheduled` — ثبت «اقدام از نوع نصب».
|
||||
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
|
||||
|
||||
قرارداد پروژه: استفاده از signal ممنوع است؛ همهچیز صریح از دلِ ویو صدا زده میشود.
|
||||
@@ -61,6 +62,23 @@ def notify_visit_scheduled(customer, action, *, created_by=None):
|
||||
)
|
||||
|
||||
|
||||
def notify_install_scheduled(customer, action, *, created_by=None):
|
||||
"""پیامک «نصب شما برای تاریخ … ثبت شد» پس از ایجاد اقدام نصب.
|
||||
|
||||
قرینهٔ :func:`notify_visit_scheduled` است و همان قرارداد را دارد: فقط برای
|
||||
اقدام ``nasb`` صدا زده میشود، متن از قالب ``install_created`` میآید و
|
||||
یکتاسازی با شناسهٔ همان رکورد اقدام انجام میشود (ویرایش دوباره = بدون تکرار).
|
||||
"""
|
||||
if customer is None or action is None or action.pk is None:
|
||||
return 0
|
||||
return enqueue_sms(
|
||||
customer=customer, sms_type=SMSType.INSTALL_CREATED,
|
||||
context={"name": customer.name_family, "date": action.actionDateTime},
|
||||
dedup_key=f"install_created:{action.pk}",
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
|
||||
created_by=None):
|
||||
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
<div class="box" dir="rtl">
|
||||
<h2 class="title is-5">پیامک</h2>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="sms_type" value="{{ item.code }}">
|
||||
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" type="text" value="{{ item.label }}" disabled>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
|
||||
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
|
||||
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">میتوانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
|
||||
<button class="button is-info" type="submit">ارسال به شمارههای اصلی</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -180,9 +180,12 @@
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "./_customer_sms.html" %}
|
||||
</div>
|
||||
{% comment %}
|
||||
باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
|
||||
(/panel/customer/<id>/sms/) منتقل شده تا فرم فاکتور تمامعرض بماند و سوابق و
|
||||
تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
|
||||
{% endcomment %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -4,30 +4,223 @@
|
||||
|
||||
{% block BodyCustomer1 %}
|
||||
|
||||
{% comment %}
|
||||
صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
|
||||
قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمینشیند؛ فقط آمار + دکمهها بالاست و
|
||||
تنظیمات/ارسال سفارشی/شمارههای اصلی داخل مودال باز میشوند.
|
||||
همهٔ کلاسها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
|
||||
صفحات دیگر نشت نکنند.
|
||||
{% endcomment %}
|
||||
<style>
|
||||
/* جدول سوابق پیامک مشتری */
|
||||
.smshist-msg {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 34rem;
|
||||
line-height: 1.9;
|
||||
{% verbatim %}
|
||||
#customerSmsHistory, #customerSmsHistory *, #customerSmsHistory *::before,
|
||||
#customerSmsHistory *::after { box-sizing: border-box; }
|
||||
/* متغیرها روی خودِ صفحه و روی ریشهٔ مودالها تعریف میشوند؛ مودالها در بلوک
|
||||
جدا (BodyCustomer2) رندر میشوند و خارج از #customerSmsHistory هستند. */
|
||||
#customerSmsHistory, .csmsx-modal { --cs-line: #e8ecf3; --cs-ink: #363636;
|
||||
--cs-muted: #7a7a7a; --cs-brand: #3273dc; --cs-ok: #48c774; }
|
||||
|
||||
/* ---------- نوار بالای صفحه ---------- */
|
||||
.csmsx-head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;
|
||||
justify-content: space-between; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
|
||||
border: 1px solid var(--cs-line); border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fd 100%); }
|
||||
.csmsx-head__main { min-width: 0; }
|
||||
.csmsx-title { font-size: 1.15rem; font-weight: 700; color: var(--cs-ink);
|
||||
margin: 0 0 .25rem; overflow-wrap: anywhere; }
|
||||
.csmsx-sub { margin: 0; color: var(--cs-muted); font-size: .82rem; line-height: 1.8; }
|
||||
.csmsx-sub b { color: var(--cs-ink); }
|
||||
.csmsx-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; }
|
||||
.csmsx-stat { display: inline-flex; align-items: center; gap: .35rem;
|
||||
font-size: .78rem; padding: .25rem .65rem; border-radius: 999px;
|
||||
background: #fff; border: 1px solid var(--cs-line); color: #4a4a4a; white-space: nowrap; }
|
||||
.csmsx-stat b { font-variant-numeric: tabular-nums; }
|
||||
.csmsx-stat.is-sent { border-color: #c8ebd5; background: #f2fbf6; }
|
||||
.csmsx-stat.is-pending { border-color: #ffeaa7; background: #fffdf3; }
|
||||
.csmsx-stat.is-failed { border-color: #ffd0d9; background: #fff5f7; }
|
||||
.csmsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
{% endverbatim %}
|
||||
</style>
|
||||
<style>
|
||||
{% verbatim %}
|
||||
/* ---------- نوار فیلترها ---------- */
|
||||
.csmsx-filters { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end;
|
||||
padding: .85rem 1rem; margin-bottom: 1rem; background: #fff;
|
||||
border: 1px solid var(--cs-line); border-radius: 12px; }
|
||||
.csmsx-filters .control { margin: 0; }
|
||||
.csmsx-filters label { display: block; font-size: .75rem; color: var(--cs-muted);
|
||||
margin-bottom: .25rem; }
|
||||
.csmsx-filters select { min-width: 10.5rem; }
|
||||
.csmsx-filters__note { margin-inline-start: auto; font-size: .78rem;
|
||||
color: var(--cs-muted); }
|
||||
|
||||
/* ---------- فهرست سوابق ---------- */
|
||||
.csmsx-history { background: #fff; border: 1px solid var(--cs-line);
|
||||
border-radius: 14px; overflow: hidden; }
|
||||
.csmsx-history__scroll { overflow-x: auto; }
|
||||
.csmsx-history table { margin: 0 !important; background: transparent; }
|
||||
.csmsx-history thead th { position: sticky; top: 0; z-index: 1; background: #f7f9fc;
|
||||
color: #4a4a4a; font-size: .78rem; font-weight: 700;
|
||||
border-bottom: 1px solid var(--cs-line) !important; }
|
||||
.csmsx-history tbody tr:hover { background: #fafcff; }
|
||||
.csmsx-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 34rem;
|
||||
line-height: 1.95; font-size: .84rem; }
|
||||
/* پیامکهای بلند (متن قالبها چندخطی است) کل جدول را بیاندازه بلند میکردند؛
|
||||
متن در ۴ خط خلاصه میشود و با «نمایش کامل» باز میشود. */
|
||||
.csmsx-msg.is-clamped { display: -webkit-box; -webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical; overflow: hidden; }
|
||||
.csmsx-expand { margin-top: .25rem; font-size: .72rem; padding: .1rem .45rem;
|
||||
border-radius: 6px; border: 1px solid var(--cs-line); background: #fff;
|
||||
color: var(--cs-brand); cursor: pointer; }
|
||||
.csmsx-expand:hover { background: #f4f8ff; }
|
||||
.csmsx-meta { font-size: .76rem; line-height: 1.7; color: var(--cs-muted); }
|
||||
.csmsx-badge { white-space: nowrap; }
|
||||
.csmsx-type-chip { display: inline-block; font-size: .75rem; padding: .1rem .5rem;
|
||||
border-radius: 999px; background: #f1f5fb; color: #33507d; white-space: nowrap; }
|
||||
{% endverbatim %}
|
||||
</style>
|
||||
<style>
|
||||
{% verbatim %}
|
||||
/* ---------- مودالها ---------- */
|
||||
.csmsx-modal .modal-content { max-width: 46rem; width: calc(100% - 1.5rem); }
|
||||
.csmsx-modal .box { padding: 0; overflow: hidden; border-radius: 14px; }
|
||||
.csmsx-modal__head { display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: .75rem; padding: 1rem 1.15rem; border-bottom: 1px solid var(--cs-line);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%); }
|
||||
.csmsx-modal__title { margin: 0; font-size: 1.02rem; font-weight: 700; color: var(--cs-ink); }
|
||||
.csmsx-modal__sub { margin: .2rem 0 0; font-size: .78rem; color: var(--cs-muted); line-height: 1.8; }
|
||||
.csmsx-modal__body { padding: 1rem 1.15rem; max-height: min(68vh, 34rem); overflow-y: auto; }
|
||||
.csmsx-modal__foot { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: flex-end;
|
||||
padding: .85rem 1.15rem; border-top: 1px solid var(--cs-line); background: #fcfcfd; }
|
||||
.csmsx-section-title { display: flex; align-items: center; gap: .45rem; margin: 0 0 .6rem;
|
||||
font-size: .82rem; font-weight: 700; color: #4a4a4a; }
|
||||
.csmsx-section + .csmsx-section { margin-top: 1.15rem; padding-top: 1.15rem;
|
||||
border-top: 1px dashed var(--cs-line); }
|
||||
|
||||
/* ردیف هر نوع پیامک داخل مودال تنظیمات */
|
||||
.csmsx-type { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: .6rem .75rem; align-items: center; padding: .6rem .7rem; border-radius: 10px; }
|
||||
.csmsx-type + .csmsx-type { margin-top: .3rem; }
|
||||
.csmsx-type:hover { background: #fafcff; }
|
||||
.csmsx-type.is-off { background: #fcfcfc; }
|
||||
.csmsx-type.is-off .csmsx-type__label { color: #9a9a9a; }
|
||||
.csmsx-type__icon { font-size: 1.15rem; line-height: 1; }
|
||||
.csmsx-type__main { min-width: 0; }
|
||||
.csmsx-type__label { display: block; font-size: .85rem; font-weight: 600;
|
||||
color: var(--cs-ink); overflow-wrap: anywhere; }
|
||||
.csmsx-type__meta { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .2rem; }
|
||||
.csmsx-tag { font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
|
||||
border: 1px solid var(--cs-line); background: #fff; color: var(--cs-muted); white-space: nowrap; }
|
||||
.csmsx-tag.is-auto { border-color: #cfe0ff; background: #f4f8ff; color: #2f5fb3; }
|
||||
|
||||
/* کلید فعال/غیرفعال (دکمهٔ submit داخل فرم) */
|
||||
.csmsx-switch { display: inline-flex; align-items: center; gap: .45rem;
|
||||
background: none; border: 0; padding: .25rem; cursor: pointer; min-height: 2.4rem; }
|
||||
.csmsx-switch__track { position: relative; flex: 0 0 auto; width: 44px; height: 25px;
|
||||
border-radius: 13px; background: #dbdbdb; transition: background .18s; }
|
||||
.csmsx-switch__track::after { content: ""; position: absolute; top: 3px;
|
||||
inset-inline-start: 3px; width: 19px; height: 19px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
|
||||
.csmsx-switch.is-on .csmsx-switch__track { background: var(--cs-ok); }
|
||||
.csmsx-switch.is-on .csmsx-switch__track::after { transform: translateX(-19px); }
|
||||
.csmsx-switch:focus-visible .csmsx-switch__track { outline: 2px solid var(--cs-brand); outline-offset: 2px; }
|
||||
.csmsx-switch__text { font-size: .76rem; color: #4a4a4a; white-space: nowrap; min-width: 3.2rem; text-align: start; }
|
||||
|
||||
/* شمارههای اصلی داخل مودال */
|
||||
.csmsx-phones { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.csmsx-phone { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .4rem .3rem .65rem;
|
||||
border: 1px solid var(--cs-line); border-radius: 999px; background: #fff; }
|
||||
.csmsx-phone__num { direction: ltr; font-size: .82rem; color: var(--cs-ink); }
|
||||
.csmsx-phone .sms-primary-toggle { border-radius: 999px; font-size: .72rem; height: 1.9rem; padding-inline: .6rem; }
|
||||
.csmsx-empty { font-size: .8rem; color: var(--cs-muted); margin: 0; }
|
||||
|
||||
/* ارسال سفارشی */
|
||||
.csmsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
|
||||
.csmsx-chip { font-size: .72rem; padding: .2rem .55rem; border-radius: 999px;
|
||||
border: 1px solid var(--cs-line); background: #fff; color: #4a4a4a; cursor: pointer;
|
||||
direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.csmsx-chip:hover { border-color: var(--cs-brand); color: var(--cs-brand); }
|
||||
.csmsx-counter { display: flex; justify-content: space-between; gap: .5rem;
|
||||
font-size: .74rem; color: var(--cs-muted); margin-top: .4rem; }
|
||||
|
||||
/* ---------- واکنشگرا ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.csmsx-head { padding: .9rem; }
|
||||
.csmsx-head__actions { width: 100%; }
|
||||
.csmsx-head__actions .button { flex: 1 1 auto; }
|
||||
.csmsx-filters { padding: .75rem; }
|
||||
.csmsx-filters select { min-width: 0; width: 100%; }
|
||||
.csmsx-filters .control { flex: 1 1 45%; }
|
||||
.csmsx-filters__note { margin-inline-start: 0; width: 100%; }
|
||||
.csmsx-modal__body { max-height: 72vh; }
|
||||
|
||||
/* موبایل: جدول به کارت تبدیل میشود. اسکرول افقی روی موبایل، متن پیامک را
|
||||
میبُرد و خواندن سوابق را عملاً غیرممکن میکرد؛ در این حالت هر پیامک یک
|
||||
کارت کامل با برچسب هر خانه است و هیچ اسکرول افقی لازم نیست. */
|
||||
.csmsx-history__scroll { overflow: visible; }
|
||||
.csmsx-history table, .csmsx-history tbody, .csmsx-history tr,
|
||||
.csmsx-history td { display: block; width: auto !important; }
|
||||
.csmsx-history thead { display: none; }
|
||||
.csmsx-history tbody tr { padding: .7rem .85rem; border-bottom: 1px solid var(--cs-line); }
|
||||
.csmsx-history tbody tr:last-child { border-bottom: 0; }
|
||||
.csmsx-history tbody td { display: flex; gap: .5rem; align-items: flex-start;
|
||||
padding: .18rem 0 !important; border: 0 !important; }
|
||||
.csmsx-history tbody td::before { content: attr(data-label); flex: 0 0 5.5rem;
|
||||
font-size: .72rem; color: var(--cs-muted); line-height: 1.9; }
|
||||
.csmsx-history tbody td[data-label=""]::before { content: none; }
|
||||
/* ستون متن: برچسب بالای متن (نه کنارش) تا عرض خواندن کم نشود. */
|
||||
.csmsx-history tbody td[data-label="متن"] { display: block; }
|
||||
.csmsx-history tbody td[data-label="متن"]::before { display: block;
|
||||
flex: none; margin-bottom: .15rem; }
|
||||
.csmsx-history tbody td > * { min-width: 0; }
|
||||
.csmsx-expand { white-space: nowrap; }
|
||||
.csmsx-msg { max-width: none; }
|
||||
.csmsx-col-id, .csmsx-col-sent, .csmsx-col-delivery-check { display: none !important; }
|
||||
}
|
||||
.smshist-meta { font-size: .78rem; line-height: 1.7; }
|
||||
.smshist-badge { white-space: nowrap; }
|
||||
{% endverbatim %}
|
||||
</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="csmsx-head">
|
||||
<div class="csmsx-head__main">
|
||||
<h1 class="csmsx-title">پیامکهای {{ customer.name_family }}</h1>
|
||||
<p class="csmsx-sub">
|
||||
جمعاً <b>{{ total_count }}</b> پیامک در سابقه ·
|
||||
<b>{{ sms_enabled_count }}</b> از <b>{{ sms_type_total }}</b> نوع پیامک برای این مشتری فعال است.
|
||||
</p>
|
||||
<div class="csmsx-stats">
|
||||
<span class="csmsx-stat is-sent">✅ ارسالشده <b>{{ stat_sent }}</b></span>
|
||||
<span class="csmsx-stat is-pending">⏳ در صف <b>{{ stat_pending }}</b></span>
|
||||
<span class="csmsx-stat is-failed">⚠️ ناموفق <b>{{ stat_failed }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="csmsx-head__actions">
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
<button type="button" class="button is-link is-light js-csmsx-open" data-target="smsSettingsModal"
|
||||
aria-haspopup="dialog">
|
||||
⚙️ تنظیمات پیامک
|
||||
</button>
|
||||
<button type="button" class="button is-info is-light js-csmsx-open" data-target="smsCustomModal"
|
||||
aria-haspopup="dialog">
|
||||
✉️ ارسال پیامک
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.admin_panel.ManageSMSSettings %}
|
||||
<a class="button is-white" href="{% url 'admin_panel:sms_templates' %}"
|
||||
title="ویرایش متن قالبهای پیامک">🗂️ متن قالبها</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- ۲) فیلترها ---------- #}
|
||||
<form method="get" class="csmsx-filters">
|
||||
<div class="control">
|
||||
<label for="csmsx-status">وضعیت ارسال</label>
|
||||
<div class="select">
|
||||
<select name="status">
|
||||
<option value="">همهٔ وضعیتهای ارسال</option>
|
||||
<select id="csmsx-status" name="status">
|
||||
<option value="">همهٔ وضعیتها</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -35,88 +228,113 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="csmsx-delivery">وضعیت تحویل</label>
|
||||
<div class="select">
|
||||
<select name="delivery">
|
||||
<option value="">همهٔ وضعیتهای تحویل</option>
|
||||
<select id="csmsx-delivery" 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>
|
||||
<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 %}
|
||||
<span class="csmsx-filters__note">
|
||||
{% if status or delivery %}
|
||||
نمایش <b>{{ filtered_count }}</b> از <b>{{ total_count }}</b> پیامک
|
||||
{% else %}
|
||||
نمایش <b>{{ total_count }}</b> پیامک، جدیدترینها ابتدا
|
||||
{% endif %}
|
||||
</span>
|
||||
</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 class="csmsx-history">
|
||||
<div class="csmsx-history__scroll">
|
||||
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="csmsx-col-id">شناسه</th>
|
||||
<th style="min-width: 8.5rem;">نوع پیامک</th>
|
||||
<th style="min-width: 8rem;">گیرنده</th>
|
||||
<th style="min-width: 16rem;">متن پیامک</th>
|
||||
<th>وضعیت ارسال</th>
|
||||
<th style="min-width: 8rem;">وضعیت تحویل</th>
|
||||
<th style="min-width: 7rem;">تاریخ ایجاد</th>
|
||||
<th class="csmsx-col-sent" style="min-width: 7rem;">تاریخ ارسال</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="csmsx-col-id csmsx-meta" data-label="شناسه">{{ item.id }}</td>
|
||||
<td data-label="نوع">
|
||||
{% if item.sms_type %}
|
||||
<span class="csmsx-type-chip">{{ item.get_sms_type_display }}</span>
|
||||
{% else %}
|
||||
<span class="csmsx-type-chip">سفارشی</span>
|
||||
{% endif %}
|
||||
{% if item.attempts %}
|
||||
<div class="csmsx-meta">تلاش: {{ item.attempts }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="csmsx-meta" dir="ltr" data-label="گیرنده">{{ item.phone_number }}</td>
|
||||
<td data-label="متن">
|
||||
<div class="csmsx-msg {% if item.message|length > 170 %}is-clamped{% endif %}">{{ item.message }}</div>
|
||||
{% if item.message|length > 170 %}
|
||||
<button type="button" class="csmsx-expand js-csmsx-expand" aria-expanded="false">نمایش کامل</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="ارسال">
|
||||
<span class="tag csmsx-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 csmsx-meta" title="{{ item.last_error }}">
|
||||
خطا: {{ item.last_error|truncatechars:80 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="تحویل">
|
||||
<span class="tag csmsx-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="csmsx-meta csmsx-col-delivery-check">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="csmsx-meta" data-label="تاریخ">{{ item.created_at|toJalaliDT }}</td>
|
||||
<td class="csmsx-meta csmsx-col-sent" data-label="ارسالشده">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="has-text-centered py-5" data-label="">پیامکی برای این مشتری ثبت نشده است.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items.has_other_pages %}
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||
<nav class="pagination is-centered mt-4" 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 %}
|
||||
@@ -135,3 +353,235 @@
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block BodyCustomer2 %}
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
|
||||
{# ---------- مودال ۱: تنظیمات پیامک (نوعها + شمارههای اصلی) ---------- #}
|
||||
<div class="modal csmsx-modal" id="smsSettingsModal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="smsSettingsTitle">
|
||||
<div class="modal-background js-csmsx-close"></div>
|
||||
<div class="modal-content">
|
||||
<div class="box" dir="rtl">
|
||||
<div class="csmsx-modal__head">
|
||||
<div>
|
||||
<h2 class="csmsx-modal__title" id="smsSettingsTitle">⚙️ تنظیمات پیامک این مشتری</h2>
|
||||
<p class="csmsx-modal__sub">
|
||||
هر تغییر بلافاصله ذخیره میشود. خاموشکردن یک نوع، فقط برای همین مشتری است؛
|
||||
متن پیامکها از <b>قالبهای پیامک</b> میآید.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__body">
|
||||
{% if notice_text and request.GET.open == 'settings' %}
|
||||
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
|
||||
<strong>{{ notice_text }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- بخش الف: شمارههای اصلی پیامک --- #}
|
||||
<section class="csmsx-section">
|
||||
<h3 class="csmsx-section-title">📱 شمارههای اصلی پیامک</h3>
|
||||
{% if phone_entries %}
|
||||
<div class="csmsx-phones">
|
||||
{% for entry in phone_entries %}
|
||||
{% if entry.is_mobile %}
|
||||
<span class="csmsx-phone">
|
||||
<span class="csmsx-phone__num">{{ entry.display }}</span>
|
||||
<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>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="csmsx-empty mt-2">
|
||||
پیامکها به شمارههای «★ اصلی» میروند؛ اگر هیچ شمارهای اصلی نباشد، شمارهٔ اول مشتری.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="csmsx-empty">شمارهای برای این مشتری ثبت نشده است.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
{# --- بخش ب: فعال/غیرفعال کردن هر نوع پیامک --- #}
|
||||
<section class="csmsx-section">
|
||||
<h3 class="csmsx-section-title">
|
||||
🔔 انواع پیامک
|
||||
<span class="csmsx-tag">{{ sms_enabled_count }} از {{ sms_type_total }} فعال</span>
|
||||
</h3>
|
||||
{% for item in sms_toggles %}
|
||||
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}"
|
||||
class="csmsx-type {% if not item.enabled %}is-off{% endif %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="sms_type" value="{{ item.code }}">
|
||||
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
|
||||
<span class="csmsx-type__icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<div class="csmsx-type__main">
|
||||
<span class="csmsx-type__label">{{ item.label }}</span>
|
||||
<span class="csmsx-type__meta">
|
||||
{% if item.when %}<span class="csmsx-tag">{{ item.when }}</span>{% endif %}
|
||||
<span class="csmsx-tag {% if item.auto_send %}is-auto{% endif %}">
|
||||
{% if item.auto_send %}ارسال خودکار{% else %}نیازمند تأیید مدیر{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" class="csmsx-switch {% if item.enabled %}is-on{% endif %}"
|
||||
role="switch" aria-checked="{% if item.enabled %}true{% else %}false{% endif %}"
|
||||
title="{% if item.enabled %}خاموش کردن{% else %}روشن کردن{% endif %} این نوع پیامک">
|
||||
<span class="csmsx-switch__track" aria-hidden="true"></span>
|
||||
<span class="csmsx-switch__text">{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__foot">
|
||||
<button type="button" class="button is-light js-csmsx-close">بستن</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- مودال ۲: ارسال پیامک سفارشی ---------- #}
|
||||
<div class="modal csmsx-modal" id="smsCustomModal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="smsCustomTitle">
|
||||
<div class="modal-background js-csmsx-close"></div>
|
||||
<div class="modal-content">
|
||||
<form class="box" dir="rtl" method="post"
|
||||
action="{% url 'admin_panel:sms_send_custom' customer.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="csmsx-modal__head">
|
||||
<div>
|
||||
<h2 class="csmsx-modal__title" id="smsCustomTitle">✉️ ارسال پیامک سفارشی</h2>
|
||||
<p class="csmsx-modal__sub">
|
||||
به شمارههای «★ اصلی» این مشتری ارسال میشود و بیدرنگ در صف قرار میگیرد.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__body">
|
||||
{% if notice_text and request.GET.open == 'custom' %}
|
||||
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
|
||||
<strong>{{ notice_text }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="csmsx-chips">
|
||||
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{name}">{name}</button>
|
||||
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{balance_sep}">{balance_sep}</button>
|
||||
</div>
|
||||
|
||||
<textarea id="csmsxCustomText" class="textarea" name="text" rows="5"
|
||||
placeholder="متن پیامک…" required
|
||||
data-counter="#csmsxCustomCounter"></textarea>
|
||||
<div class="csmsx-counter">
|
||||
<span>{name} با نام مشتری، {balance_sep} با مانده حساب جایگزین میشود.</span>
|
||||
<span id="csmsxCustomCounter">۰ نویسه</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__foot">
|
||||
<button type="button" class="button is-light js-csmsx-close">انصراف</button>
|
||||
<button type="submit" class="button is-info">ارسال پیامک</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// باز/بسته کردن مودالهای این صفحه + درج متغیر در متن + شمارندهٔ نویسه.
|
||||
// بدون کتابخانهٔ جدید؛ کلید Esc و کلیک روی پسزمینه هم مودال را میبندد.
|
||||
(function () {
|
||||
if (window.__csmsxModalInit) { return; }
|
||||
window.__csmsxModalInit = true;
|
||||
|
||||
function openModal(id) {
|
||||
var modal = document.getElementById(id);
|
||||
if (!modal) { return; }
|
||||
modal.classList.add('is-active');
|
||||
var field = modal.querySelector('textarea');
|
||||
if (field) { field.focus(); }
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
if (modal) { modal.classList.remove('is-active'); }
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!event.target.closest) { return; }
|
||||
|
||||
var opener = event.target.closest('.js-csmsx-open');
|
||||
if (opener) {
|
||||
event.preventDefault();
|
||||
openModal(opener.getAttribute('data-target'));
|
||||
return;
|
||||
}
|
||||
var closer = event.target.closest('.js-csmsx-close');
|
||||
if (closer) {
|
||||
event.preventDefault();
|
||||
closeModal(closer.closest('.modal'));
|
||||
return;
|
||||
}
|
||||
var expand = event.target.closest('.js-csmsx-expand');
|
||||
if (expand) {
|
||||
event.preventDefault();
|
||||
var box = expand.parentNode.querySelector('.csmsx-msg');
|
||||
if (box) {
|
||||
var opened = box.classList.toggle('is-clamped') === false;
|
||||
expand.setAttribute('aria-expanded', opened ? 'true' : 'false');
|
||||
expand.textContent = opened ? 'خلاصه' : 'نمایش کامل';
|
||||
}
|
||||
return;
|
||||
}
|
||||
var chip = event.target.closest('.js-csmsx-token');
|
||||
if (chip) {
|
||||
event.preventDefault();
|
||||
var field = document.getElementById('csmsxCustomText');
|
||||
if (!field) { return; }
|
||||
var token = chip.getAttribute('data-token') || '';
|
||||
var start = field.selectionStart == null ? field.value.length : field.selectionStart;
|
||||
var end = field.selectionEnd == null ? field.value.length : field.selectionEnd;
|
||||
field.value = field.value.slice(0, start) + token + field.value.slice(end);
|
||||
field.selectionStart = field.selectionEnd = start + token.length;
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
field.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key !== 'Escape' && event.key !== 'Esc') { return; }
|
||||
var active = document.querySelector('#smsSettingsModal.is-active, #smsCustomModal.is-active');
|
||||
closeModal(active);
|
||||
});
|
||||
|
||||
document.addEventListener('input', function (event) {
|
||||
var field = event.target;
|
||||
if (!field || field.id !== 'csmsxCustomText') { return; }
|
||||
var target = document.querySelector(field.getAttribute('data-counter'));
|
||||
if (!target) { return; }
|
||||
// ارقام فارسی، همخوان با بقیهٔ متن صفحه.
|
||||
var count = String(field.value.length)
|
||||
.replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'.charAt(Number(d)); });
|
||||
target.textContent = count + ' نویسه';
|
||||
});
|
||||
|
||||
// باز شدن خودکار مودال بر اساس ?open= (بعد از ذخیرهٔ تنظیمات یا خطای ارسال).
|
||||
var wanted = new URLSearchParams(window.location.search).get('open');
|
||||
if (wanted === 'settings') { openModal('smsSettingsModal'); }
|
||||
if (wanted === 'custom') { openModal('smsCustomModal'); }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,9 +11,10 @@ from django.utils import timezone
|
||||
from persiantools.jdatetime import JalaliDate
|
||||
|
||||
from ..models import Action, Customer
|
||||
from ..sms.triggers import notify_visit_scheduled
|
||||
from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
|
||||
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||
ActionDeleteError, as_aware, clear_visit_hold,
|
||||
ActionDeleteError, as_aware,
|
||||
cancel_pending_sms_for_action, clear_visit_hold,
|
||||
default_hold_until, normalize_finish_datetime,
|
||||
normalize_hold_until, refresh_last_bazdid,
|
||||
set_visit_hold, soft_delete_action,
|
||||
@@ -159,6 +160,45 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False
|
||||
return result
|
||||
|
||||
|
||||
# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
|
||||
# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمیزد).
|
||||
_ACTION_EVENT_SMS = {
|
||||
Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
|
||||
Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
|
||||
}
|
||||
|
||||
|
||||
def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
|
||||
"""همراستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
|
||||
|
||||
* نوع جدید **رویدادی** است (بازدید/نصب):
|
||||
- اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
|
||||
میشود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
|
||||
- اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
|
||||
نمیشود، پس ویرایشهای پیاپی اسپم تولید نمیکنند.
|
||||
* نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
|
||||
لغو میشود (کاربر عملاً بازدید/نصب را پس گرفته است).
|
||||
|
||||
خروجی: تعداد پیامکهای ساختهشده.
|
||||
"""
|
||||
current = _ACTION_EVENT_SMS.get(action.actionType)
|
||||
previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
|
||||
|
||||
# نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
|
||||
# وگرنه هر دو پیامک در صف میمانند.
|
||||
if previous is not None and previous_type != action.actionType:
|
||||
cancel_pending_sms_for_action(
|
||||
customer, action_id=action.pk, sms_type=previous[1],
|
||||
)
|
||||
|
||||
if current is None:
|
||||
# نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
|
||||
return 0
|
||||
if previous_type == action.actionType:
|
||||
return 0 # ویرایش عادی همان نوع ⇒ پیامک تازهای لازم نیست
|
||||
return current[0](customer, action, created_by=user)
|
||||
|
||||
|
||||
def _jalali_day(value):
|
||||
"""تاریخ شمسی روز (به وقت محلی) برای پیامهای کاربر."""
|
||||
if value is None:
|
||||
@@ -232,9 +272,8 @@ def customerNewAction(request ,c_id):
|
||||
)
|
||||
_report_hold_result(request, hold_result)
|
||||
|
||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
||||
if newAction.actionType == Action.ActionType.BAZDID:
|
||||
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
|
||||
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
|
||||
_sync_action_event_sms(customer_obj, newAction, user=request.user)
|
||||
|
||||
return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
|
||||
@@ -270,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
|
||||
customer_obj = Customer.objects.get(id=int(c_id))
|
||||
newAction = Action.objects.get(id=action_id)
|
||||
was_done = newAction.status == Action.Status.FINISH
|
||||
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
|
||||
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
|
||||
previous_type = newAction.actionType
|
||||
|
||||
newAction.actionType = payload['actionType']
|
||||
newAction.actionDateTime = payload['actionDateTime']
|
||||
@@ -297,6 +339,15 @@ def customerEditAction(request ,c_id, action_id):
|
||||
)
|
||||
_report_hold_result(request, hold_result)
|
||||
|
||||
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
|
||||
# فقط *گذار* نوع اقدام بررسی میشود، نه هر ویرایش (جزئیات در
|
||||
# ``_sync_action_event_sms``):
|
||||
# * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده میشود.
|
||||
# * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو میشود.
|
||||
_sync_action_event_sms(
|
||||
customer_obj, newAction, previous_type=previous_type, user=request.user,
|
||||
)
|
||||
|
||||
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
|
||||
from ..sms.phones import (normalize_primary_values,
|
||||
save_primary_normalized)
|
||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||
appointment_sources_map, as_aware,
|
||||
followups_on_date, last_visit_map, overdue_followups,
|
||||
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
|
||||
#all_prd = serializers.serialize("json", all_prda)
|
||||
#return JsonResponse(all_prd,safe = False)
|
||||
dateNow = datetime.datetime.now().date
|
||||
sms_toggles = customer_toggle_map(customer)
|
||||
data = {
|
||||
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
|
||||
'factorha': factorha, 'mandehesabCs': mandehesabCs,
|
||||
'sms_toggles': sms_toggles,
|
||||
}
|
||||
return render(request, 'admin_panel/customerNew2.html', data)
|
||||
|
||||
@@ -494,8 +491,8 @@ def entezar(request):
|
||||
|
||||
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
|
||||
# این نما **مستقل** از «عقبافتاده» است: مشتری سالمی که با او قرار تماس
|
||||
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
|
||||
# ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید نمیآید).
|
||||
# گذاشته شده هم باید دیده شود. قاعدهٔ حذفِ «بازدید/نصب» در خود
|
||||
# ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید یا نصب نمیآید).
|
||||
today_followups = followups_on_date(today_date, now=now)
|
||||
|
||||
# پیگیریهای عقبافتاده: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
|
||||
|
||||
+79
-13
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
|
||||
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
|
||||
stored_primary_normalized)
|
||||
from ..sms.render import format_jalali, render_template
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..sms.timing import apply_timing, current_timing_value, timing_for
|
||||
|
||||
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
|
||||
@@ -58,6 +59,16 @@ def _notice(url, code, **extra):
|
||||
def _customer_url(c_id):
|
||||
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
|
||||
|
||||
|
||||
def _customer_sms_url(c_id):
|
||||
"""صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
|
||||
|
||||
تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
|
||||
(تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همینجا بماند، نه به
|
||||
صفحهٔ اصلی مشتری پرتاب شود.
|
||||
"""
|
||||
return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
|
||||
|
||||
# متغیرهای قابل استفاده در متن قالبها (برای راهنمای پنل).
|
||||
TEMPLATE_VARIABLES = [
|
||||
("{name}", "نام مشتری"),
|
||||
@@ -79,6 +90,7 @@ SMS_TYPE_UI = {
|
||||
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
|
||||
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
|
||||
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
|
||||
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
|
||||
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
|
||||
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
|
||||
}
|
||||
@@ -313,8 +325,12 @@ def sms_customer_toggle(request, c_id):
|
||||
)
|
||||
setting.is_enabled = enabled
|
||||
setting.save()
|
||||
return _notice(_customer_url(c_id), "customer_sms_saved")
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز میشود تا کاربر
|
||||
# چند نوع را پشتسرهم روشن/خاموش کند و هر بار مودال را باز نکند.
|
||||
return _notice(
|
||||
_customer_sms_url(c_id), "customer_sms_saved", open="settings",
|
||||
)
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -336,13 +352,13 @@ def sms_primary_toggle(request, c_id):
|
||||
"",
|
||||
)
|
||||
if not normalized:
|
||||
return _notice(_customer_url(c_id), "primary_not_found")
|
||||
return _notice(_customer_sms_url(c_id), "primary_not_found")
|
||||
current = [n for n in stored_primary_normalized(customer) if n != normalized]
|
||||
if enabled:
|
||||
current.append(normalized)
|
||||
save_primary_normalized(customer, current)
|
||||
return _notice(_customer_url(c_id), "primary_updated")
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
return _notice(_customer_sms_url(c_id), "primary_updated")
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -358,15 +374,16 @@ def sms_send_custom(request, c_id):
|
||||
text = (request.POST.get("text", "") or "").strip()
|
||||
rendered = render_template(text, {"name": customer.name_family}).strip()
|
||||
if not rendered:
|
||||
return _notice(_customer_url(c_id), "custom_empty")
|
||||
# خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
|
||||
return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
|
||||
created = enqueue_sms(
|
||||
customer=customer, sms_type="", message=rendered,
|
||||
created_by=request.user,
|
||||
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
|
||||
)
|
||||
code = "custom_queued" if created else "custom_no_phone"
|
||||
return _notice(_customer_url(c_id), code)
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
return _notice(_customer_sms_url(c_id), code)
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -428,11 +445,17 @@ def sms_queue(request):
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@_manage_required
|
||||
def customer_sms_history(request, c_id):
|
||||
"""سوابق پیامکهای یک مشتری روی صفحهٔ خود مشتری.
|
||||
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
|
||||
|
||||
همهٔ پیامکهایی که برای شمارههای این مشتری در صف قرار گرفته/ارسال شدهاند
|
||||
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده میشوند و
|
||||
فهرست صفحهبندی میشود. با ``?status=`` و ``?delivery=`` میتوان فیلتر کرد.
|
||||
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
|
||||
|
||||
* لایهٔ اول (همیشه دیده میشود): آمار کوتاه، فیلترها و فهرست سوابق.
|
||||
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شمارههای اصلی و ارسال سفارشی،
|
||||
همه داخل مودالهای Bulma.
|
||||
|
||||
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
|
||||
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
|
||||
دو تمامعرض بمانند.
|
||||
"""
|
||||
customer = get_object_or_404(Customer, id=c_id)
|
||||
status = request.GET.get("status", "")
|
||||
@@ -446,6 +469,34 @@ def customer_sms_history(request, c_id):
|
||||
qs = qs.filter(delivery_status=delivery)
|
||||
paginator = Paginator(qs, 20)
|
||||
page = paginator.get_page(request.GET.get("page"))
|
||||
|
||||
# --- آمار کارتهای بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
|
||||
all_items = SMSQueue.objects.filter(customer=customer)
|
||||
status_counts = dict(
|
||||
all_items.order_by().values_list("status").annotate(n=Count("id"))
|
||||
)
|
||||
total_count = all_items.count()
|
||||
|
||||
# --- تنظیمات: نوعهای پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
|
||||
# ``auto_send`` تنظیمی سراسری است (صفحهٔ قالبها)، ولی دانستنش اینجا کمک
|
||||
# میکند کاربر بداند پیامکِ این نوع بیدرنگ میرود یا منتظر تأیید میماند.
|
||||
auto_send = dict(
|
||||
SMSTemplate.objects.values_list("sms_type", "auto_send")
|
||||
)
|
||||
type_labels = dict(SMSType.choices)
|
||||
sms_toggles = [
|
||||
{
|
||||
"code": item["code"],
|
||||
"label": item["label"],
|
||||
"enabled": item["enabled"],
|
||||
"auto_send": bool(auto_send.get(item["code"])),
|
||||
"icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
|
||||
"when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
|
||||
}
|
||||
for item in customer_toggle_map(customer)
|
||||
]
|
||||
enabled_count = sum(1 for item in sms_toggles if item["enabled"])
|
||||
|
||||
data = {
|
||||
"customer": customer,
|
||||
"items": page,
|
||||
@@ -453,8 +504,23 @@ def customer_sms_history(request, c_id):
|
||||
"delivery": delivery,
|
||||
"status_choices": SMSQueue.Status.choices,
|
||||
"delivery_choices": SMSQueue.DeliveryStatus.choices,
|
||||
"total_count": SMSQueue.objects.filter(customer=customer).count(),
|
||||
"total_count": total_count,
|
||||
"filtered_count": paginator.count,
|
||||
# تنظیمات (مودال)
|
||||
"sms_toggles": sms_toggles,
|
||||
"sms_enabled_count": enabled_count,
|
||||
"sms_type_total": len(type_labels),
|
||||
"phone_entries": customer_phone_entries(customer),
|
||||
# آمار (نوار بالای صفحه)
|
||||
"status_counts": status_counts,
|
||||
"stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
|
||||
# «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
|
||||
"stat_pending": (
|
||||
status_counts.get(SMSQueue.Status.PENDING, 0)
|
||||
+ status_counts.get(SMSQueue.Status.APPROVED, 0)
|
||||
+ status_counts.get(SMSQueue.Status.PROCESSING, 0)
|
||||
),
|
||||
"stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
|
||||
}
|
||||
return render(request, "admin_panel/customerSmsHistory.html", data)
|
||||
|
||||
|
||||
+115
-58
@@ -437,38 +437,56 @@ def _valid_manual_holds(holds):
|
||||
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
|
||||
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
|
||||
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
|
||||
coordination_latest = dict(
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids,
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
# «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده میشود، نه فقط createdDT.
|
||||
# چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
|
||||
# شوند مقدار یکسان میگیرند؛ با مقایسهٔ صرفاً `>` آنگاه «بازدید بعد از
|
||||
# هماهنگی» تشخیص داده نمیشد و قرارِ مرده معتبر میماند. id یکتای افزایشی
|
||||
# همان ترتیب ثبت را میدهد، پس بهعنوان مرزِ تساوی استفاده میشود.
|
||||
coordination_latest = {
|
||||
cid: (last, last_id)
|
||||
for cid, last, last_id in (
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids,
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"), last_id=Max("id"))
|
||||
.values_list("customer_id", "last", "last_id")
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"))
|
||||
.values_list("customer_id", "last")
|
||||
)
|
||||
}
|
||||
# آخرین فعالیتِ **جایگزینکنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
|
||||
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
|
||||
other_latest = dict(
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
|
||||
other_latest = {
|
||||
cid: (last, last_id)
|
||||
for cid, last, last_id in (
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"), last_id=Max("id"))
|
||||
.values_list("customer_id", "last", "last_id")
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"))
|
||||
.values_list("customer_id", "last")
|
||||
)
|
||||
}
|
||||
|
||||
valid = {}
|
||||
for customer_id, until in holds.items():
|
||||
if until is None:
|
||||
continue
|
||||
coordinated_at = coordination_latest.get(customer_id)
|
||||
if coordinated_at is None:
|
||||
continue
|
||||
activity_at = other_latest.get(customer_id)
|
||||
if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at):
|
||||
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
|
||||
coordination = coordination_latest.get(customer_id)
|
||||
if coordination is None:
|
||||
continue
|
||||
coordinated_at, coordinated_id = coordination
|
||||
activity = other_latest.get(customer_id)
|
||||
if activity is not None:
|
||||
activity_at, activity_id = activity
|
||||
newer = as_aware(activity_at) > as_aware(coordinated_at) or (
|
||||
as_aware(activity_at) == as_aware(coordinated_at)
|
||||
and activity_id > coordinated_id
|
||||
)
|
||||
if newer:
|
||||
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
|
||||
continue
|
||||
valid[customer_id] = until
|
||||
return valid
|
||||
|
||||
@@ -509,19 +527,25 @@ def _local_day_bounds(target_date):
|
||||
)
|
||||
|
||||
|
||||
def _pending_visit_customer_ids(*, since):
|
||||
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
|
||||
def _pending_appointment_ids(*, since):
|
||||
"""مشتریانی که **قرار بازدید/نصب** دارند و نباید در فهرست پیگیری بیایند.
|
||||
|
||||
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
|
||||
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
|
||||
(آغاز روز مورد نظر) عقبتر نباشد، از فهرست پیگیری **حذف** میشود.
|
||||
قاعدهٔ محصولی: «اگر برای مشتری بازدید یا نصب گذاشته شده، دیگر لازم نیست
|
||||
تماس بگیریم». پس هر مشتری با اقدام ``bazdid``/``nasb`` و وضعیت ``waite`` که
|
||||
تاریخش از ``since`` (آغاز روز مورد نظر) عقبتر نباشد، از فهرست پیگیری
|
||||
**حذف** میشود.
|
||||
|
||||
چرا «نصب» هم؟ همان دو نوع، چرخهٔ بازدید را از نو شروع میکنند
|
||||
(``_CYCLE_RESET_TYPES``) و قرار دستی را باطل میکنند؛ پس فهرست کار روزانه
|
||||
هم باید همین قاعده را داشته باشد. قبلاً فقط ``bazdid`` دیده میشد و
|
||||
«نصبِ برنامهریزیشده» ردیف را باز میگذاشت.
|
||||
|
||||
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
|
||||
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
|
||||
"""
|
||||
return set(
|
||||
Action.objects.filter(
|
||||
actionType=Action.ActionType.BAZDID,
|
||||
actionType__in=_CYCLE_RESET_TYPES,
|
||||
status=Action.Status.WAITE,
|
||||
)
|
||||
.filter(
|
||||
@@ -547,8 +571,8 @@ def followups_on_date(target_date, *, now=None):
|
||||
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
|
||||
|
||||
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
|
||||
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
|
||||
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
||||
مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
|
||||
بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
||||
"""
|
||||
day_start, day_end = _local_day_bounds(target_date)
|
||||
|
||||
@@ -593,28 +617,33 @@ def followups_on_date(target_date, *, now=None):
|
||||
if current is None or when < current:
|
||||
bucket[SOURCE_COORDINATION] = when
|
||||
|
||||
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
|
||||
# ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
|
||||
if result:
|
||||
for customer_id in _pending_visit_customer_ids(since=day_start):
|
||||
for customer_id in _pending_appointment_ids(since=day_start):
|
||||
result.pop(customer_id, None)
|
||||
|
||||
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
|
||||
# قاعدهٔ کسبوکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
|
||||
# بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
||||
# ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
|
||||
# قاعدهٔ کسبوکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
|
||||
# فقط بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
||||
# «نیازمند پیگیری امروز» میماند (۹۲٪ ردیفهای دادهٔ واقعی).
|
||||
# لنگر از :func:`cycle_anchor_map` میآید — یعنی همان دو نوعی که چرخه را از نو
|
||||
# شروع میکنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
|
||||
# قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمیکند و «نصب» هم ردیف
|
||||
# را میبندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
|
||||
# (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
|
||||
# فقط برای امروز/آینده اعمال میشود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
|
||||
# همان روز را با دادههای بعدی بازنویسی نکند.
|
||||
if result:
|
||||
now = _now(now)
|
||||
current_tz = timezone.get_current_timezone()
|
||||
if target_date >= now.astimezone(current_tz).date():
|
||||
visits = last_visit_map(now=now, customer_ids=list(result))
|
||||
for customer_id, visited_at in visits.items():
|
||||
anchors = cycle_anchor_map(now=now, customer_ids=list(result))
|
||||
for customer_id, done_at in anchors.items():
|
||||
bucket = result.get(customer_id)
|
||||
if not bucket or visited_at is None:
|
||||
if not bucket or done_at is None:
|
||||
continue
|
||||
due_day = min(bucket.values()).astimezone(current_tz).date()
|
||||
if visited_at.astimezone(current_tz).date() >= due_day:
|
||||
if done_at.astimezone(current_tz).date() >= due_day:
|
||||
result.pop(customer_id, None)
|
||||
|
||||
return result
|
||||
@@ -631,10 +660,10 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
|
||||
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
|
||||
(موعدش تازه است).
|
||||
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج میشود — همان قاعدهٔ
|
||||
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
|
||||
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج میشود
|
||||
(پیگیری با بازدید بسته شده است).
|
||||
* اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج میشود — همان
|
||||
قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
|
||||
* اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
|
||||
میشود (پیگیری با بازدید/نصب بسته شده است).
|
||||
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
|
||||
خارج میشود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
|
||||
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
|
||||
@@ -669,10 +698,10 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
if not overdue:
|
||||
return {}
|
||||
|
||||
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
|
||||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدیدی که بعد از آن گذاشته شده
|
||||
# تکلیف مشتری را روشن کرده است.
|
||||
pending_visits = _pending_visit_customer_ids(
|
||||
# حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
|
||||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدید/نصبی که بعد از آن گذاشته
|
||||
# شده تکلیف مشتری را روشن کرده است.
|
||||
pending_visits = _pending_appointment_ids(
|
||||
since=_local_day_bounds(min(overdue.values()))[0]
|
||||
)
|
||||
for customer_id in pending_visits:
|
||||
@@ -680,14 +709,16 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
|
||||
current_tz = timezone.get_current_timezone()
|
||||
|
||||
# بازدیدِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
||||
# بازدید/نصبِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
||||
# قبلاً فقط بازدیدِ «در انتظار» دیده میشد و همین، فهرست «از روزهای قبل»
|
||||
# را پر از مشتریانی میکرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
|
||||
visits = last_visit_map(now=now, customer_ids=list(overdue))
|
||||
for customer_id, visited_at in visits.items():
|
||||
if visited_at is None:
|
||||
# لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
|
||||
# بازدید پیگیری را ببندد.
|
||||
anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
|
||||
for customer_id, done_at in anchors.items():
|
||||
if done_at is None:
|
||||
continue
|
||||
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
||||
if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
||||
overdue.pop(customer_id, None)
|
||||
|
||||
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
|
||||
@@ -1008,14 +1039,24 @@ class ActionDeleteError(Exception):
|
||||
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
|
||||
|
||||
|
||||
def cancel_pending_sms_for_action(customer, *, now=None):
|
||||
"""لغو پیامکهای **ارسالنشدهٔ** «بازدید ثبت شد» همان مشتری.
|
||||
def cancel_pending_sms_for_action(customer, *, action_id=None, sms_type=None, now=None):
|
||||
"""لغو پیامکهای **ارسالنشدهٔ** رویدادیِ همان مشتری («بازدید/نصب ثبت شد»).
|
||||
|
||||
چرا با ``action`` پیدا نمیکنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
|
||||
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
|
||||
دیگر وجود ندارد، پیامکهای معلق همان مشتری با نوع ``visit_created`` لغو
|
||||
میشوند. پیامکهای **ارسالشده** دست نمیخورند (سابقه است).
|
||||
|
||||
``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیشفرض ``visit_created``
|
||||
(رفتار قبلی، که حذف اقدام از آن استفاده میکند). برای «نصب» مقدار
|
||||
``install_created`` داده میشود.
|
||||
|
||||
``action_id`` (اختیاری): فقط پیامکهای همان رکورد اقدام را لغو میکند. کلید
|
||||
یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
|
||||
«ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
|
||||
را از بین ببرد. پیشفرض (``None``) همهٔ پیامکهای معلق همان نوع را لغو
|
||||
میکند.
|
||||
|
||||
خروجی: تعداد رکوردهای لغوشده.
|
||||
"""
|
||||
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
|
||||
@@ -1024,16 +1065,24 @@ def cancel_pending_sms_for_action(customer, *, now=None):
|
||||
if customer is None or customer.pk is None:
|
||||
return 0
|
||||
now = _now(now)
|
||||
sms_type = sms_type or SMSType.VISIT_CREATED
|
||||
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
|
||||
return (
|
||||
query = (
|
||||
SMSQueue.objects.filter(
|
||||
customer_id=customer.pk,
|
||||
sms_type=SMSType.VISIT_CREATED,
|
||||
sms_type=sms_type,
|
||||
status__in=pending,
|
||||
)
|
||||
.exclude(status=SMSQueue.Status.SENT)
|
||||
.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
|
||||
)
|
||||
if action_id is not None:
|
||||
# ``startswith`` تنها کافی نیست: کلید اقدام ۷۷ پیشوندِ کلید اقدام ۷۷۵ هم
|
||||
# هست. پس مرز «:» صریح گذاشته میشود (شکل ``…:{action_id}:{number}``).
|
||||
base = "%s:%s" % (sms_type, action_id)
|
||||
query = query.filter(
|
||||
models.Q(dedup_key=base) | models.Q(dedup_key__startswith=base + ":")
|
||||
)
|
||||
return query.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
|
||||
|
||||
|
||||
def soft_delete_action(action, *, user=None, now=None):
|
||||
@@ -1051,7 +1100,8 @@ def soft_delete_action(action, *, user=None, now=None):
|
||||
۱. ``last_bazdid_dt`` بازمحاسبه میشود (کش کهنه نماند).
|
||||
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود
|
||||
(قاعدهٔ «ستون، مرجع نیست»).
|
||||
۳. پیامکهای **ارسالنشده**ی همان مشتری لغو میشوند.
|
||||
۳. پیامکهای **ارسالنشده**ی رویدادیِ همان مشتری لغو میشوند (``visit_created``
|
||||
و برای اقدام «نصب»، ``install_created``).
|
||||
|
||||
ممنوعیت: اقدامی که نظرسنجی ثبتشده دارد (``nazarDT`` پر است) حذف نمیشود؛
|
||||
زیرا هیچ آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد.
|
||||
@@ -1081,6 +1131,13 @@ def soft_delete_action(action, *, user=None, now=None):
|
||||
if customer is not None:
|
||||
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
|
||||
cancelled = cancel_pending_sms_for_action(customer, now=now)
|
||||
# اقدام «نصب» پیامک رویدادیِ خودش را هم دارد (نوع جدا)؛ حذف آن باید
|
||||
# پیامک معلقش را هم لغو کند، وگرنه پیامک «نصب ثبت شد» بیمرجع میماند.
|
||||
if action.actionType == Action.ActionType.NASB:
|
||||
from .models import SMSType
|
||||
cancelled += cancel_pending_sms_for_action(
|
||||
customer, sms_type=SMSType.INSTALL_CREATED, now=now,
|
||||
)
|
||||
else:
|
||||
cancelled = 0
|
||||
|
||||
|
||||
Reference in new issue
Block a user