some improve

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

No files matched your search

@@ -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),
]
+2 -1
View File
@@ -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", "یادآوری سررسید نسیه"
+3 -2
View File
@@ -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),
+5
View File
@@ -46,6 +46,11 @@ TIMING = {
"field": None,
"note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.INSTALL_CREATED: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت «اقدام از نوع نصب» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.PAYMENT_THANKS: {
"kind": NONE,
"field": None,
+18
View File
@@ -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 %}
+56 -5
View File
@@ -11,9 +11,10 @@ from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
ActionDeleteError, as_aware, clear_visit_hold,
ActionDeleteError, as_aware,
cancel_pending_sms_for_action, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
@@ -159,6 +160,45 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False
return result
# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمی‌زد).
_ACTION_EVENT_SMS = {
Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
}
def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
"""هم‌راستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
* نوع جدید **رویدادی** است (بازدید/نصب):
- اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
می‌شود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
- اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
نمی‌شود، پس ویرایش‌های پیاپی اسپم تولید نمی‌کنند.
* نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
لغو می‌شود (کاربر عملاً بازدید/نصب را پس گرفته است).
خروجی: تعداد پیامک‌های ساخته‌شده.
"""
current = _ACTION_EVENT_SMS.get(action.actionType)
previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
# نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
# وگرنه هر دو پیامک در صف می‌مانند.
if previous is not None and previous_type != action.actionType:
cancel_pending_sms_for_action(
customer, action_id=action.pk, sms_type=previous[1],
)
if current is None:
# نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
return 0
if previous_type == action.actionType:
return 0 # ویرایش عادی همان نوع ⇒ پیامک تازه‌ای لازم نیست
return current[0](customer, action, created_by=user)
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر."""
if value is None:
@@ -232,9 +272,8 @@ def customerNewAction(request ,c_id):
)
_report_hold_result(request, hold_result)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
_sync_action_event_sms(customer_obj, newAction, user=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@@ -270,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
was_done = newAction.status == Action.Status.FINISH
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
previous_type = newAction.actionType
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
@@ -297,6 +339,15 @@ def customerEditAction(request ,c_id, action_id):
)
_report_hold_result(request, hold_result)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
# فقط *گذار* نوع اقدام بررسی می‌شود، نه هر ویرایش (جزئیات در
# ``_sync_action_event_sms``):
# * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده می‌شود.
# * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو می‌شود.
_sync_action_event_sms(
customer_obj, newAction, previous_type=previous_type, user=request.user,
)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
+2 -5
View File
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
sms_toggles = customer_toggle_map(customer)
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
'sms_toggles': sms_toggles,
}
return render(request, 'admin_panel/customerNew2.html', data)
@@ -494,8 +491,8 @@ def entezar(request):
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
# این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید).
# گذاشته شده هم باید دیده شود. قاعدهٔ حذفِ «بازدید/نصب» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید یا نصب نمی‌آید).
today_followups = followups_on_date(today_date, now=now)
# پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
+79 -13
View File
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
from ..sms.rules import customer_toggle_map
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
@@ -58,6 +59,16 @@ def _notice(url, code, **extra):
def _customer_url(c_id):
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
def _customer_sms_url(c_id):
"""صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
(تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همین‌جا بماند، نه به
صفحهٔ اصلی مشتری پرتاب شود.
"""
return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
# متغیرهای قابل استفاده در متن قالب‌ها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
@@ -79,6 +90,7 @@ SMS_TYPE_UI = {
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
}
@@ -313,8 +325,12 @@ def sms_customer_toggle(request, c_id):
)
setting.is_enabled = enabled
setting.save()
return _notice(_customer_url(c_id), "customer_sms_saved")
return redirect("admin_panel:customer", c_id=c_id)
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز می‌شود تا کاربر
# چند نوع را پشت‌سرهم روشن/خاموش کند و هر بار مودال را باز نکند.
return _notice(
_customer_sms_url(c_id), "customer_sms_saved", open="settings",
)
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -336,13 +352,13 @@ def sms_primary_toggle(request, c_id):
"",
)
if not normalized:
return _notice(_customer_url(c_id), "primary_not_found")
return _notice(_customer_sms_url(c_id), "primary_not_found")
current = [n for n in stored_primary_normalized(customer) if n != normalized]
if enabled:
current.append(normalized)
save_primary_normalized(customer, current)
return _notice(_customer_url(c_id), "primary_updated")
return redirect("admin_panel:customer", c_id=c_id)
return _notice(_customer_sms_url(c_id), "primary_updated")
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -358,15 +374,16 @@ def sms_send_custom(request, c_id):
text = (request.POST.get("text", "") or "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
return _notice(_customer_url(c_id), "custom_empty")
# خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
created = enqueue_sms(
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
return _notice(_customer_url(c_id), code)
return redirect("admin_panel:customer", c_id=c_id)
return _notice(_customer_sms_url(c_id), code)
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -428,11 +445,17 @@ def sms_queue(request):
@login_required(login_url='/panel/p_login/')
@_manage_required
def customer_sms_history(request, c_id):
"""سوابق پیامک‌های یک مشتری روی صفحهٔ خود مشتری.
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
همهٔ پیامک‌هایی که برای شماره‌های این مشتری در صف قرار گرفته/ارسال شده‌اند
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده می‌شوند و
فهرست صفحه‌بندی می‌شود. با ``?status=`` و ``?delivery=`` می‌توان فیلتر کرد.
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
* لایهٔ اول (همیشه دیده می‌شود): آمار کوتاه، فیلترها و فهرست سوابق.
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شماره‌های اصلی و ارسال سفارشی،
همه داخل مودال‌های Bulma.
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
دو تمام‌عرض بمانند.
"""
customer = get_object_or_404(Customer, id=c_id)
status = request.GET.get("status", "")
@@ -446,6 +469,34 @@ def customer_sms_history(request, c_id):
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
# --- آمار کارت‌های بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
all_items = SMSQueue.objects.filter(customer=customer)
status_counts = dict(
all_items.order_by().values_list("status").annotate(n=Count("id"))
)
total_count = all_items.count()
# --- تنظیمات: نوع‌های پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
# ``auto_send`` تنظیمی سراسری است (صفحهٔ قالب‌ها)، ولی دانستنش اینجا کمک
# می‌کند کاربر بداند پیامکِ این نوع بی‌درنگ می‌رود یا منتظر تأیید می‌ماند.
auto_send = dict(
SMSTemplate.objects.values_list("sms_type", "auto_send")
)
type_labels = dict(SMSType.choices)
sms_toggles = [
{
"code": item["code"],
"label": item["label"],
"enabled": item["enabled"],
"auto_send": bool(auto_send.get(item["code"])),
"icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
"when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
}
for item in customer_toggle_map(customer)
]
enabled_count = sum(1 for item in sms_toggles if item["enabled"])
data = {
"customer": customer,
"items": page,
@@ -453,8 +504,23 @@ def customer_sms_history(request, c_id):
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"total_count": SMSQueue.objects.filter(customer=customer).count(),
"total_count": total_count,
"filtered_count": paginator.count,
# تنظیمات (مودال)
"sms_toggles": sms_toggles,
"sms_enabled_count": enabled_count,
"sms_type_total": len(type_labels),
"phone_entries": customer_phone_entries(customer),
# آمار (نوار بالای صفحه)
"status_counts": status_counts,
"stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
# «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
"stat_pending": (
status_counts.get(SMSQueue.Status.PENDING, 0)
+ status_counts.get(SMSQueue.Status.APPROVED, 0)
+ status_counts.get(SMSQueue.Status.PROCESSING, 0)
),
"stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
}
return render(request, "admin_panel/customerSmsHistory.html", data)
+115 -58
View File
@@ -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